Compare commits

1 Commits
Author SHA1 Message Date
Atreides Assets 6e741a51fe Versie 0.3.3 2026-10-30 12:28:48 +01:00
57 changed files with 1362 additions and 2135 deletions

No files matched your search

+1 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:98f4651e5cb7036965f92ae8206b5dd6cbf62ce4e42388c68825bc8d9de6ca9d // Generated, do not edit. sha256:aa77d2c2decbc66af6d9704bd9aeefcdd539cbf767ac6818b097169d4cfa5aa8
import { configureSharedUi } from './ui/shared-setup.js'; import { configureSharedUi } from './ui/shared-setup.js';
import './edition.js'; import './edition.js';
import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js'; import { assertConfig, ROUTE_KEYS, hashFromLocation } from '../shared/config.js';
@@ -21,7 +21,6 @@ import { clearPageHint } from './ui/page-hint.js';
import { refreshRecentCloses } from './price/providers.js'; import { refreshRecentCloses } from './price/providers.js';
import * as home from './pages/home.js'; import * as home from './pages/home.js';
import * as conversionBitcoin from './pages/conversion-bitcoin.js'; import * as conversionBitcoin from './pages/conversion-bitcoin.js';
import * as priceHistory from './pages/price-history.js';
import * as wealth from './pages/wealth.js'; import * as wealth from './pages/wealth.js';
import * as events from './pages/events.js'; import * as events from './pages/events.js';
import * as tracking from './pages/tracking.js'; import * as tracking from './pages/tracking.js';
@@ -41,7 +40,6 @@ registerRoute(ROUTE_KEYS.EVENTS, events);
registerRoute(ROUTE_KEYS.VALIDATION, makeStub('validation')); registerRoute(ROUTE_KEYS.VALIDATION, makeStub('validation'));
registerRoute(ROUTE_KEYS.TRACKING, tracking); registerRoute(ROUTE_KEYS.TRACKING, tracking);
registerRoute(ROUTE_KEYS.CONVERSION_BITCOIN, conversionBitcoin); registerRoute(ROUTE_KEYS.CONVERSION_BITCOIN, conversionBitcoin);
registerRoute(ROUTE_KEYS.PRICE_HISTORY, priceHistory);
registerRoute(ROUTE_KEYS.SYSTEM, system); registerRoute(ROUTE_KEYS.SYSTEM, system);
registerRoute(ROUTE_KEYS.ACTIONS, actions); registerRoute(ROUTE_KEYS.ACTIONS, actions);
registerRoute(ROUTE_KEYS.GUIDE, guide); registerRoute(ROUTE_KEYS.GUIDE, guide);
+59 -38
View File
@@ -1,4 +1,4 @@
/* Generated, do not edit. sha256:d875d85b0d41e419eb6bae7090608b0914629b07925648a2d82dc7a8e8eb4309 */ /* Generated, do not edit. sha256:3a6f73b7505bd7ec634402e61c222e218812ab7432c634bed61f9c0331123db5 */
:root { :root {
--logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E"); --logo-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'%3E%3Cpath d='M 46.108 37.099 L 79.826 68.032 L 113.068 37.288 L 142.067 37.244 L 92.564 85.773 L 92.367 123.544 L 80.04 135.851 L 67.262 123.517 L 67.425 85.773 L 17.176 37.092 L 46.108 37.099 Z'/%3E%3Cpath d='M 49.901 91.273 L 0 43.722 L 0 24.666 L 64.78 87.731 L 64.689 118.25 L 50.234 105.711 L 49.901 91.273 Z'/%3E%3Cpath d='M 95.221 87.779 L 160 24.149 L 160 43.383 L 109.554 93.918 L 109.584 105.756 L 94.924 118.584 L 95.221 87.779 Z'/%3E%3C/svg%3E");
--radius-sm: 8px; --radius-sm: 8px;
@@ -1220,8 +1220,8 @@ tbody tr[data-row-id]:hover .exp-cell .chev-btn { color:var(--accent); }
.ip-due-row > .ip-num { min-width:5.5rem; } .ip-due-row > .ip-num { min-width:5.5rem; }
.ip-due-btns { display:flex; gap:0.4rem; } .ip-due-btns { display:flex; gap:0.4rem; }
.table-wrap.sticky-sel tbody tr.row-selected > td { position:sticky; top:var(--sticky-row-top, 2.7rem); z-index:2; background:color-mix(in srgb, var(--accent) 20%, var(--bg-elevated)); } .table-wrap.sticky-sel > table > tbody > tr.row-selected > td { position:sticky; top:var(--sticky-row-top, 2.7rem); z-index:2; background:color-mix(in srgb, var(--accent) 20%, var(--bg-elevated)); }
.table-wrap.sticky-sel tbody tr.row-selected:hover > td { background:color-mix(in srgb, var(--accent) 30%, var(--bg-elevated)); } .table-wrap.sticky-sel > table > tbody > tr.row-selected:hover > td { background:color-mix(in srgb, var(--accent) 30%, var(--bg-elevated)); }
.cmd-bar { .cmd-bar {
position:fixed; left:50%; bottom:1.8rem; transform:translateX(-50%); z-index:60; position:fixed; left:50%; bottom:1.8rem; transform:translateX(-50%); z-index:60;
@@ -1365,6 +1365,37 @@ button.wealth-year-chip:hover { background:var(--accent-mid); }
} }
.pf-blok + .pf-blok { margin-top:1.4rem; padding-top:1.4rem; border-top:1px solid var(--border); } .pf-blok + .pf-blok { margin-top:1.4rem; padding-top:1.4rem; border-top:1px solid var(--border); }
.pf-blok:first-of-type { margin-top:1rem; } .pf-blok:first-of-type { margin-top:1rem; }
.pf-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(24rem, 100%), 1fr)); grid-auto-rows:1fr; gap:1.5rem; }
.pf-grid-archive { grid-auto-rows:auto; align-items:start; margin-top:1.5rem; }
.pf-grid.is-stacked { grid-template-columns:minmax(0, 1fr); max-width:46rem; }
.pf-card { display:flex; flex-direction:column; }
.pf-card.is-open { border-color:var(--accent); }
.pf-head { display:flex; align-items:center; gap:0.7rem; flex-wrap:wrap; }
.pf-head-end { margin-left:auto; }
.pf-name { font-size:1.9rem; font-weight:700; letter-spacing:-0.025em; line-height:1.15; }
.pf-sub { margin-top:0.3rem; min-height:1.4em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pf-facts { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.6rem; margin:1.3rem 0 0; }
.pf-fact { min-width:0; padding:0.65rem 0.85rem; border-radius:var(--radius-sm); background:var(--surface-tint); }
.pf-fact:empty { background:transparent; }
.pf-fact > * { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pf-fact dt { font-size:0.78rem; color:var(--text-ter); }
.pf-fact dd { margin:0.15rem 0 0; font-variant-numeric:tabular-nums; }
.pf-fact.is-late dd { color:var(--sig-yellow); }
.pf-kinds { list-style:none; margin:0.45rem 0 0; padding:0; min-height:calc(var(--pf-lines, 1) * 1.45em); font-size:0.8rem; color:var(--text-ter); white-space:normal; }
.pf-kinds li { display:flex; justify-content:space-between; gap:0.8rem; line-height:1.45; }
.pf-kinds li > span:last-child { font-variant-numeric:tabular-nums; color:var(--text-sec); }
.pf-actions { display:flex; align-items:center; gap:0.5rem; margin-top:auto; padding-top:1.4rem; }
.pf-actions:empty { display:none; }
.pf-more { margin-left:auto; min-width:2.2rem; letter-spacing:0.08em; }
.pf-archive { display:block; }
.dialog--wide.pf-settings-dialog { max-width:min(36rem, 100%); }
.pf-archive > summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:0.6rem; }
.pf-archive > summary::-webkit-details-marker { display:none; }
.pf-archive[open] > summary .chev-btn { transform:rotate(180deg); }
.pf-archive-list { display:flex; flex-direction:column; gap:0.9rem; margin-top:1.2rem; }
.pf-new { display:flex; align-items:center; justify-content:center; gap:0.5rem; min-height:12rem; padding:1.6rem; border:1.5px dashed var(--border-mid); border-radius:var(--radius-lg); color:var(--text-ter); }
.pf-new-btn { width:4rem; height:4rem; border-radius:var(--radius-lg); }
.pf-new-btn svg { width:28px; height:28px; }
.wealth-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(22rem, 100%), 1fr)); gap:1.2rem; align-items:stretch; } .wealth-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(22rem, 100%), 1fr)); gap:1.2rem; align-items:stretch; }
.gap-list { max-height:16rem; overflow-y:auto; margin-top:0.4rem; } .gap-list { max-height:16rem; overflow-y:auto; margin-top:0.4rem; }
@@ -1471,6 +1502,7 @@ textarea.notes-body { flex:1 1 auto; min-height:8rem; resize:none; line-height:1
.return-w-amount { margin:0.4rem 0 0.1rem; color:var(--text-sec); } .return-w-amount { margin:0.4rem 0 0.1rem; color:var(--text-sec); }
.return-w-pct { font-size:0.85rem; font-variant-numeric:tabular-nums; } .return-w-pct { font-size:0.85rem; font-variant-numeric:tabular-nums; }
.return-view--widget { margin-top:0.6rem; } .return-view--widget { margin-top:0.6rem; }
.return-view--widget .return-plot > .t-muted-cell { display:grid; place-items:center; height:100%; padding:0 var(--padding-x); text-align:center; }
.return-view--widget .return-plot { height:8rem; margin:0 calc(-1 * var(--padding-x)) calc(-1 * var(--padding)); .return-view--widget .return-plot { height:8rem; margin:0 calc(-1 * var(--padding-x)) calc(-1 * var(--padding));
overflow:hidden; border-radius:0 0 var(--radius) var(--radius); } overflow:hidden; border-radius:0 0 var(--radius) var(--radius); }
.wealth-detail .return-plot { height:min(12rem, 22vh); } .wealth-detail .return-plot { height:min(12rem, 22vh); }
@@ -1526,6 +1558,8 @@ html:not(:has(.page-layout > .page-aside)) .hint-panel {
width:min(44rem, calc(100vw - var(--hint-line-x) - 2.8rem)); width:min(44rem, calc(100vw - var(--hint-line-x) - 2.8rem));
} }
.hint:hover .hint-panel, .hint-btn:focus-visible + .hint-panel { opacity:1; visibility:visible; transform:translateY(0); } .hint:hover .hint-panel, .hint-btn:focus-visible + .hint-panel { opacity:1; visibility:visible; transform:translateY(0); }
.hint.is-open .hint-panel { opacity:1; visibility:visible; transform:translateY(0); pointer-events:auto; }
.hint.is-open .hint-btn { opacity:1; color:var(--accent-text); }
@media (min-width:769px) { @media (min-width:769px) {
#page-hint .hint-panel.is-wide { #page-hint .hint-panel.is-wide {
right:auto; left:calc(var(--hint-line-x) + 1.4rem); right:auto; left:calc(var(--hint-line-x) + 1.4rem);
@@ -1721,11 +1755,13 @@ tbody tr:hover .copy-btn, .mv-row:hover .copy-btn, .card-flat:hover .copy-btn {
.flex { display:flex; align-items:center; } .flex-between { display:flex; align-items:center; justify-content:space-between; } .flex { display:flex; align-items:center; } .flex-between { display:flex; align-items:center; justify-content:space-between; }
.hidden { display:none !important; } .hidden { display:none !important; }
.price-hero { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; margin-bottom:1.2rem; } .conv-card { container-type:inline-size; display:flex; flex-direction:column; gap:1.2rem; }
.price-label { margin-bottom:0.35rem; display:flex; align-items:center; gap:0.5rem; } .price-label { margin-bottom:0.35rem; display:flex; align-items:center; gap:0.5rem; }
.price-val { font-size:2.1rem; letter-spacing:-0.03em; line-height:1.1; } .price-val { font-size:2.1rem; letter-spacing:-0.03em; line-height:1.1; }
.price-meta { font-size:0.72rem; color:var(--text-ter); margin-top:0.35rem; } .price-meta { font-size:0.72rem; color:var(--text-ter); margin-top:0.35rem; }
.conv-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.9rem; } .conv-grid { display:grid; grid-template-columns:repeat(6, minmax(0, 1fr)); gap:0.9rem; }
@container (max-width:980px) { .conv-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
@container (max-width:480px) { .conv-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
.field { display:flex; flex-direction:column; gap:0.6rem; background:var(--bg-elevated); border:1px solid var(--border); --radius:var(--radius-lg); --padding:1.1rem; border-radius:var(--radius); padding:1.1rem 1.2rem; transition:border-color 0.15s, background 0.25s; } .field { display:flex; flex-direction:column; gap:0.6rem; background:var(--bg-elevated); border:1px solid var(--border); --radius:var(--radius-lg); --padding:1.1rem; border-radius:var(--radius); padding:1.1rem 1.2rem; transition:border-color 0.15s, background 0.25s; }
.field:focus-within { border-color:var(--accent); } .field:focus-within { border-color:var(--accent); }
.field-head { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; } .field-head { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; }
@@ -1734,15 +1770,7 @@ tbody tr:hover .copy-btn, .mv-row:hover .copy-btn, .card-flat:hover .copy-btn {
.field-input::placeholder { color:var(--text-ter); font-weight:400; } .field-input::placeholder { color:var(--text-ter); font-weight:400; }
.field-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); } .field-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
.field-hint { font-size:0.7rem; color:var(--text-sec); font-variant-numeric:tabular-nums; } .field-hint { font-size:0.7rem; color:var(--text-sec); font-variant-numeric:tabular-nums; }
.info { background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:1.3rem 1.4rem; margin-top:1.2rem; }
.info-title { margin-bottom:0.8rem; }
.info-body { font-size:0.82rem; color:var(--text-sec); line-height:1.8; font-variant-numeric:tabular-nums; }
.info-body strong { color:var(--text-primary); font-weight:600; }
.info-note { margin-top:0.7rem; font-size:0.8rem; color:var(--text-sec); }
.conv-footer { margin-top:1.5rem; text-align:center; font-size:0.72rem; color:var(--text-ter); }
@media (max-width:560px) { @media (max-width:560px) {
.price-hero { flex-direction:column; align-items:stretch; }
.conv-grid { grid-template-columns:1fr; }
.price-val { font-size:1.8rem; } .price-val { font-size:1.8rem; }
} }
@@ -1759,11 +1787,7 @@ tbody tr:hover .copy-btn, .mv-row:hover .copy-btn, .card-flat:hover .copy-btn {
.lic-head { display:flex; align-items:baseline; justify-content:space-between; gap:1rem; margin-bottom:1.2rem; } .lic-head { display:flex; align-items:baseline; justify-content:space-between; gap:1rem; margin-bottom:1.2rem; }
.lic-card p { margin:0 0 0.8rem; overflow-wrap:break-word; } .lic-card p { margin:0 0 0.8rem; overflow-wrap:break-word; }
.lic-drop { display:grid; gap:0.2rem; place-items:center; padding:1.6rem; border:1px dashed var(--border-mid); border-radius:var(--radius-md); cursor:pointer; transition:border-color 0.15s, background 0.15s; } .lic-layout { display:grid; gap:1.5rem; align-items:start; grid-template-columns:minmax(0, 1fr) minmax(17rem, 24rem); }
.lic-drop:hover, .lic-drop:focus-visible { border-color:var(--accent); }
.lic-drop.is-over { border-color:var(--accent); background:var(--accent-soft); }
.lic-layout { display:grid; gap:1.5rem; align-items:start; grid-template-columns:minmax(20rem, 28rem) minmax(0, 1fr); }
.lic-side, .lic-main { display:flex; flex-direction:column; gap:1.5rem; min-width:0; } .lic-side, .lic-main { display:flex; flex-direction:column; gap:1.5rem; min-width:0; }
.lic-hero { display:flex; flex-direction:column; align-items:center; gap:1rem; margin-bottom:1.6rem; } .lic-hero { display:flex; flex-direction:column; align-items:center; gap:1rem; margin-bottom:1.6rem; }
@@ -1771,7 +1795,8 @@ tbody tr:hover .copy-btn, .mv-row:hover .copy-btn, .card-flat:hover .copy-btn {
.lic-feat-row { display:grid; grid-template-columns:minmax(0, 1fr) 5rem 5rem; align-items:center; justify-items:center; .lic-feat-row { display:grid; grid-template-columns:minmax(0, 1fr) 5rem 5rem; align-items:center; justify-items:center;
padding:0.4rem 0.5rem; border-radius:var(--radius-sm); cursor:default; } padding:0.4rem 0.5rem; border-radius:var(--radius-sm); cursor:default; }
.lic-feat-row > :first-child { justify-self:start; } .lic-feat-row > :first-child { justify-self:start; }
.lic-feat-row[data-feature]:hover, .lic-feat-row[data-feature]:focus-visible, .lic-feat-row.is-on { background:var(--neutral-bg); outline:none; } .lic-feat-row[data-feature]:focus-visible, .lic-feat-row.is-on { background:var(--neutral-bg); outline:none; }
.lic-feat-row :is(.lic-feat-yes, .lic-feat-no, .lic-feat-part, .lic-feat-open) { cursor:help; }
.lic-feat-cols { font-weight:500; color:var(--text-sec); font-size:0.8rem; padding-top:0; } .lic-feat-cols { font-weight:500; color:var(--text-sec); font-size:0.8rem; padding-top:0; }
.lic-group + .lic-group { border-top:1px solid var(--border); } .lic-group + .lic-group { border-top:1px solid var(--border); }
.lic-group > summary { list-style:none; cursor:pointer; padding:0.7rem 0.5rem; font-size:1.05rem; font-weight:500; } .lic-group > summary { list-style:none; cursor:pointer; padding:0.7rem 0.5rem; font-size:1.05rem; font-weight:500; }
@@ -1784,26 +1809,22 @@ tbody tr:hover .copy-btn, .mv-row:hover .copy-btn, .card-flat:hover .copy-btn {
.lic-feat-yes svg { width:1.4rem; height:1.4rem; } .lic-feat-yes svg { width:1.4rem; height:1.4rem; }
.lic-feat-sub .lic-feat-yes svg { width:1.1rem; height:1.1rem; } .lic-feat-sub .lic-feat-yes svg { width:1.1rem; height:1.1rem; }
.lic-feat-no, .lic-feat-open { color:var(--text-sec); font-size:1.2rem; line-height:1; } .lic-feat-no, .lic-feat-open { color:var(--text-sec); font-size:1.2rem; line-height:1; }
.lic-feat-part { color:var(--text-sec); font-size:0.8rem; font-weight:400; } .lic-feat-part { color:var(--text-ter); display:inline-flex; }
.lic-feat-part svg { width:1.4rem; height:1.4rem; }
.lic-feat-shot[hidden] { display:none; } .lic-feat-shot[hidden] { display:none; }
.lic-feat-shot { position:fixed; z-index:1000; width:min(34rem, calc(100vw - 1rem)); aspect-ratio:16 / 10; display:grid; place-items:center; .lic-feat-shot { position:fixed; z-index:1000; width:min(34rem, calc(100vw - 1rem)); aspect-ratio:16 / 10; display:grid; place-items:center;
padding:0.6rem; text-align:center; overflow:hidden; pointer-events:none; padding:0.6rem; text-align:center; overflow:hidden; pointer-events:none;
background:var(--bg-raised); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:0 12px 40px rgba(0,0,0,0.35); } background:var(--bg-raised); border:1px solid var(--border-mid); border-radius:var(--radius-md); box-shadow:0 12px 40px rgba(0,0,0,0.35); }
.lic-feat-img { max-width:100%; max-height:100%; object-fit:contain; border-radius:var(--radius-sm); }.lic-section { border-top:1px solid var(--border); margin-top:1.2rem; padding-top:1.2rem; } .lic-feat-img { max-width:100%; max-height:100%; object-fit:contain; border-radius:var(--radius-sm); }.lic-section { border-top:1px solid var(--border); margin-top:1.2rem; padding-top:1.2rem; }
.lic-card .lic-drop { padding:0.8rem; margin-bottom:0.6rem; } .lic-section[hidden] { display:none; }
.lic-apps { margin:0.2rem 0 0.8rem; } .lic-card .lic-status { margin-top:0.9rem; }
.lic-apps .t-caption { margin-bottom:0.3rem; } .lic-attach { margin-left:auto; display:inline-flex; align-items:center; }
.lic-card .lic-apps p { margin:0 0 0.4rem; }
.lic-more summary { cursor:pointer; color:var(--text-sec); margin-top:0.4rem; }
.lic-more[open] summary { margin-bottom:0.8rem; }
.lic-chat-card { position:sticky; top:0; display:flex; flex-direction:column; height:calc(100vh - 11rem); min-height:30rem; } .lic-chat-card { display:flex; flex-direction:column; }
.lic-chat-card[hidden] { display:none; } .lic-chat-card[hidden] { display:none; }
.lic-chat { flex:1; display:flex; flex-direction:column; gap:0.8rem; min-height:8rem; overflow-y:auto; padding:0.25rem 0.25rem 0.5rem; margin-bottom:1rem; } .lic-chat { display:flex; flex-direction:column; gap:0.8rem; min-height:6rem; max-height:20rem; overflow-y:auto; padding:0.25rem 0.25rem 0.5rem; margin-bottom:1rem; }
@media (max-width:1100px) { @media (max-width:820px) {
.lic-layout { grid-template-columns:minmax(0, 1fr); } .lic-layout { grid-template-columns:minmax(0, 1fr); }
.lic-chat-card { position:static; height:auto; }
.lic-chat { flex:none; max-height:28rem; }
} }
.lic-msg { display:flex; flex-direction:column; gap:0.2rem; max-width:75%; } .lic-msg { display:flex; flex-direction:column; gap:0.2rem; max-width:75%; }
.lic-msg.is-mine { align-self:flex-end; align-items:flex-end; } .lic-msg.is-mine { align-self:flex-end; align-items:flex-end; }
@@ -2261,7 +2282,7 @@ td:hover .po-btn, .po-host:hover .po-btn { color:var(--accent-text); }
.table-wrap tr.row-editing td[data-label] .input, .table-wrap tr.row-editing td[data-label] .input,
.table-wrap tr.row-editing td[data-label] .conv-field { width:100%; } .table-wrap tr.row-editing td[data-label] .conv-field { width:100%; }
.table-wrap.sticky-sel tbody tr.row-selected > td { position:static; } .table-wrap.sticky-sel > table > tbody > tr.row-selected > td { position:static; }
} }
.health-start { display:flex; align-items:center; gap:0.8rem; flex-wrap:wrap; margin-bottom:0.6rem; } .health-start { display:flex; align-items:center; gap:0.8rem; flex-wrap:wrap; margin-bottom:0.6rem; }
@@ -2388,22 +2409,22 @@ td:hover .po-btn, .po-host:hover .po-btn { color:var(--accent-text); }
:root[data-profile="glass"][data-rowsel="pill"] .table-wrap.sticky-sel { :root[data-profile="glass"][data-rowsel="pill"] .table-wrap.sticky-sel {
--sel-fill:color-mix(in srgb, var(--accent) 8%, transparent); --sel-fill-hover:color-mix(in srgb, var(--accent) 13%, transparent); --sel-fill:color-mix(in srgb, var(--accent) 8%, transparent); --sel-fill-hover:color-mix(in srgb, var(--accent) 13%, transparent);
} }
:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected { background:none; } :root[data-profile="glass"] .table-wrap.sticky-sel > table > tbody > tr.row-selected { background:none; }
:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected > td { :root[data-profile="glass"] .table-wrap.sticky-sel > table > tbody > tr.row-selected > td {
background:linear-gradient(var(--sel-fill), var(--sel-fill)); background:linear-gradient(var(--sel-fill), var(--sel-fill));
backdrop-filter:blur(var(--float-blur)); -webkit-backdrop-filter:blur(var(--float-blur)); backdrop-filter:blur(var(--float-blur)); -webkit-backdrop-filter:blur(var(--float-blur));
} }
:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected:hover > td { :root[data-profile="glass"] .table-wrap.sticky-sel > table > tbody > tr.row-selected:hover > td {
background:linear-gradient(var(--sel-fill-hover), var(--sel-fill-hover)); background:linear-gradient(var(--sel-fill-hover), var(--sel-fill-hover));
} }
:root[data-profile="glass"]:not([data-rowsel="tint"], [data-rowsel="pill"]) .table-wrap.sticky-sel tbody tr.row-selected > td { :root[data-profile="glass"]:not([data-rowsel="tint"], [data-rowsel="pill"]) .table-wrap.sticky-sel > table > tbody > tr.row-selected > td {
background-image:linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%); background-image:linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%);
background-size:var(--row-w, 100%) 100%; background-size:var(--row-w, 100%) 100%;
background-position:calc(-1 * var(--cell-x, 0px)) 0; background-position:calc(-1 * var(--cell-x, 0px)) 0;
background-repeat:no-repeat; background-repeat:no-repeat;
transition:background-color 0.25s; transition:background-color 0.25s;
} }
:root[data-profile="glass"]:not([data-rowsel="tint"], [data-rowsel="pill"]) .table-wrap.sticky-sel tbody tr.row-selected:hover > td { :root[data-profile="glass"]:not([data-rowsel="tint"], [data-rowsel="pill"]) .table-wrap.sticky-sel > table > tbody > tr.row-selected:hover > td {
background-image:linear-gradient(var(--row-hover), var(--row-hover)), linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%); background-image:linear-gradient(var(--row-hover), var(--row-hover)), linear-gradient(192deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent) 5%, transparent) 45%, transparent 85%);
background-size:100% 100%, var(--row-w, 100%) 100%; background-size:100% 100%, var(--row-w, 100%) 100%;
background-position:0 0, calc(-1 * var(--cell-x, 0px)) 0; background-position:0 0, calc(-1 * var(--cell-x, 0px)) 0;
@@ -2572,7 +2593,7 @@ td:hover .po-btn, .po-host:hover .po-btn { color:var(--accent-text); }
} }
:root[data-profile="glass"] .dialog::before { background:var(--bg-elevated); backdrop-filter:none; -webkit-backdrop-filter:none; } :root[data-profile="glass"] .dialog::before { background:var(--bg-elevated); backdrop-filter:none; -webkit-backdrop-filter:none; }
:root[data-profile="glass"] .table-wrap { --head-fill:var(--bg-elevated); } :root[data-profile="glass"] .table-wrap { --head-fill:var(--bg-elevated); }
:root[data-profile="glass"] .table-wrap.sticky-sel tbody tr.row-selected > td { :root[data-profile="glass"] .table-wrap.sticky-sel > table > tbody > tr.row-selected > td {
background:color-mix(in srgb, var(--accent) 20%, var(--bg-elevated)); backdrop-filter:none; -webkit-backdrop-filter:none; background:color-mix(in srgb, var(--accent) 20%, var(--bg-elevated)); backdrop-filter:none; -webkit-backdrop-filter:none;
} }
} }
+3 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:890b0e46a8d4907e79564933d64186ce26573ba305bedb6fd4fa2f911700fadc // Generated, do not edit. sha256:bcd0e4843e173e4a2001dd1d3c642e65fcb325797f24f09e8d0e152ff13debcf
import { getPref, setPref } from '../data/preferences.js'; import { getPref, setPref } from '../data/preferences.js';
import { import {
activateProfile, activeProfile, createProfile, deleteProfile, listProfiles, listWidgets, activateProfile, activeProfile, createProfile, deleteProfile, listProfiles, listWidgets,
@@ -375,6 +375,7 @@ async function profileAction(host, opts, act, btn) {
const res = await dialog.form({ const res = await dialog.form({
title: t('dashboard.profileNew'), title: t('dashboard.profileNew'),
fields: [{ key: 'name', label: t('dashboard.profileNewPrompt'), value: '' }], fields: [{ key: 'name', label: t('dashboard.profileNewPrompt'), value: '' }],
confirmLabel: t('common.create'),
}); });
if (!res || !String(res.name || '').trim()) return; if (!res || !String(res.name || '').trim()) return;
await activateProfile(await createProfile({ name: res.name })); await activateProfile(await createProfile({ name: res.name }));
@@ -383,6 +384,7 @@ async function profileAction(host, opts, act, btn) {
const res = await dialog.form({ const res = await dialog.form({
title: t('dashboard.profileRename'), title: t('dashboard.profileRename'),
fields: [{ key: 'name', label: t('dashboard.profileRenamePrompt'), value: huidig.name }], fields: [{ key: 'name', label: t('dashboard.profileRenamePrompt'), value: huidig.name }],
confirmLabel: t('common.rename'),
}); });
if (!res || !String(res.name || '').trim()) return; if (!res || !String(res.name || '').trim()) return;
await renameProfile(huidig.id, { name: res.name }); await renameProfile(huidig.id, { name: res.name });
-10
View File
@@ -1,10 +0,0 @@
// Generated, do not edit. sha256:3d2479ecc3a9b127fd96f6f5e0ff246e37273a0096ff4f6b4225e400506ea204
import { rpc } from './_rpc.js';
export const acceptHygieneFinding = (...args) => rpc('hygiene', 'acceptHygieneFinding', ...args);
export const fillDerivablePriceOrigins = (...args) => rpc('hygiene', 'fillDerivablePriceOrigins', ...args);
export const hygieneFingerprint = (...args) => rpc('hygiene', 'hygieneFingerprint', ...args);
export const hygieneKinds = (...args) => rpc('hygiene', 'hygieneKinds', ...args);
export const hygieneReport = (...args) => rpc('hygiene', 'hygieneReport', ...args);
export const listHygieneAcceptances = (...args) => rpc('hygiene', 'listHygieneAcceptances', ...args);
export const unacceptHygieneFinding = (...args) => rpc('hygiene', 'unacceptHygieneFinding', ...args);
-7
View File
@@ -1,7 +0,0 @@
// Generated, do not edit. sha256:c0d23ea37ddc6695c69eb1b6531c8b951887f5c30452dd318610fcfcb868477c
import { rpc } from './_rpc.js';
export const clearTable = (...args) => rpc('table-viewer', 'clearTable', ...args);
export const deleteTableRow = (...args) => rpc('table-viewer', 'deleteTableRow', ...args);
export const listTableRaw = (...args) => rpc('table-viewer', 'listTableRaw', ...args);
export const tableColumns = (...args) => rpc('table-viewer', 'tableColumns', ...args);
+60 -61
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:a167e18e55f858b6191c5f1c50bdab5b3bf485873744837c0e096e77f639cd0e // Generated, do not edit. sha256:b049624ea50293251291f47db3c7beed3e81db7ec2c5e36ca24f472c360c5f37
import { nl } from './nl.js'; import { nl } from './nl.js';
export const en = { export const en = {
@@ -13,6 +13,8 @@ export const en = {
requestTooLarge: 'The request is too large.', requestTooLarge: 'The request is too large.',
fileTooLarge: 'The file is too large.', fileTooLarge: 'The file is too large.',
invalidAmount: 'Invalid amount.', invalidAmount: 'Invalid amount.',
create: 'Create',
rename: 'Rename',
}, },
splash: { splash: {
@@ -72,20 +74,14 @@ export const en = {
'why_unknown-version': 'The app version cannot be determined', 'why_unknown-version': 'The app version cannot be determined',
why_licensed: 'Valid license', why_licensed: 'Valid license',
cardTitle: 'License', cardTitle: 'License',
cardFree: 'This installation runs the free version: no time limit, with a small set of records and ' cardFree: 'Free version: no time limit, with a small set of records. Nothing is ever removed.',
+ 'without the connections, the source choice and the taxes page. Reading and exporting always ' cardFull: 'Full version: all limits lifted.',
+ 'keep working; nothing is ever removed.', cardFor: 'This license is for {npub}',
cardBuy: 'The paid version lifts those limits. If you have a license file, drop it below.', cardFacts: 'Issued on {issued}, valid for major versions {from} through {to}.',
cardFull: 'This installation runs the full version. All limits are lifted and every feature is '
+ 'available.',
cardDevelop: 'This is a development version: below 1.0.0 the app restricts nothing.',
cardFacts: 'The license is for {buyer}, issued on {issued} and valid for major versions {from} '
+ 'through {to}.',
cardOutOfRange: 'This app is version {version} and falls outside that range.',
cardProblem: 'There is a license file, but the app cannot use it.', cardProblem: 'There is a license file, but the app cannot use it.',
cardVersion: 'Version {version}', cardVersion: 'Version {version}',
signInTitle: 'Sign in', signInTitle: 'Sign in',
signInIntro: 'Your license belongs to a nostr key. Sign in to fetch the licenses for that key.', signInIntro: 'Sign in with your nostr key to buy or fetch your license.',
signInLast: 'Last signed in as {npub}.', signInLast: 'Last signed in as {npub}.',
signInExtension: 'With extension', signInExtension: 'With extension',
signInExtensionNone: 'No nostr extension found in this browser (such as Alby or nos2x).', signInExtensionNone: 'No nostr extension found in this browser (such as Alby or nos2x).',
@@ -126,7 +122,6 @@ export const en = {
buyClosed: 'Sales are closed at the moment.', buyClosed: 'Sales are closed at the moment.',
buyAway: 'Atreides Services cannot be reached right now, so buying is not possible at the moment. ' buyAway: 'Atreides Services cannot be reached right now, so buying is not possible at the moment. '
+ 'Try again later.', + 'Try again later.',
buySignInFirst: 'Sign in first: the license is issued to your key.',
buyButton: 'Buy', buyButton: 'Buy',
buyInvoice: 'Pay this invoice with a Lightning wallet. It is valid until {time}.', buyInvoice: 'Pay this invoice with a Lightning wallet. It is valid until {time}.',
buyOpenWallet: 'Open in wallet', buyOpenWallet: 'Open in wallet',
@@ -183,8 +178,7 @@ export const en = {
+ 'ID check).', + 'ID check).',
payNote: 'As of October 2026: check the terms with the provider.', payNote: 'As of October 2026: check the terms with the provider.',
nostrMore: 'About nostr and your key', nostrMore: 'About nostr and your key',
signInKnown: 'Your license is on {npub}. Sign with that key to fetch licenses, buy or ask for ' signInKnown: 'Use the key this license is on.',
+ 'support; the npub alone does not prove it is you.',
infoNostrHead: 'What is nostr?', infoNostrHead: 'What is nostr?',
infoNostr: 'Nostr is an open protocol in which your identity is a key pair. The public key (npub) is ' infoNostr: 'Nostr is an open protocol in which your identity is a key pair. The public key (npub) is '
+ 'like an address: anyone may see it. The secret key (nsec) is like a password that no one can ' + 'like an address: anyone may see it. The secret key (nsec) is like a password that no one can '
@@ -211,17 +205,14 @@ export const en = {
+ 'made unrecognizable, so it can only be matched if you come forward with it yourself.', + 'made unrecognizable, so it can only be matched if you come forward with it yourself.',
name_fiatAccounts: 'Fiat accounts', name_fiatAccounts: 'Fiat accounts',
name_assets: 'Assets besides currencies', name_assets: 'Assets besides currencies',
name_valuations: 'Balances',
name_portfolios: 'Portfolios', name_portfolios: 'Portfolios',
featTitle: 'What the editions include', featTitle: 'What the editions include',
featFull: 'Full', featFull: 'Full',
featFree: 'Free', featFree: 'Free',
featFreeOpen: 'What the free edition includes is not settled yet.',
featNoShot: 'No screenshot of this feature yet.', featNoShot: 'No screenshot of this feature yet.',
featIncluded: 'Included', featIncluded: 'Included',
featExcluded: 'Not included', featExcluded: 'Not included',
featUndecided: 'Not decided yet', featUndecided: 'Not decided yet',
featPartial: 'partly',
featPartialTitle: 'Part of it is included', featPartialTitle: 'Part of it is included',
featGroup_overview: 'Overview and reports', featGroup_overview: 'Overview and reports',
featGroup_bitcoin: 'Bitcoin and Lightning', featGroup_bitcoin: 'Bitcoin and Lightning',
@@ -238,22 +229,19 @@ export const en = {
feat_balances: 'Balances and reconcile', feat_balances: 'Balances and reconcile',
feat_ownMempool: 'Your own mempool node', feat_ownMempool: 'Your own mempool node',
feat_priceHistory: 'Price history', feat_priceHistory: 'Price history',
feat_sourceChoice: 'Source choice per asset', feat_sourceChoice: 'Source choice matrix',
feat_providerKeys: 'Your own price sources and API keys', feat_providerKeys: 'Managing your own integrations and API keys',
feat_signals: 'Bitcoin signals, charts and widget', feat_signals: 'Bitcoin signals, charts and widget',
feat_netshare: 'Network share widget', feat_netshare: 'Network share widget',
feat_portfolios: 'Multiple portfolios', feat_portfolios: 'Multiple portfolios',
feat_ai: 'AI connection (MCP)', feat_ai: 'AI connection (MCP)',
feat_accountMerge: 'Merging accounts',
feat_import: 'Import and export', feat_import: 'Import and export',
feat_data: 'Data: the raw tables', feat_data: 'Data: the raw tables',
feat_health: 'Health: checks on your records', feat_health: 'Health: checks on your records',
feat_kiosk: 'The app in its own window', feat_kiosk: 'The app in its own window',
feat_metals: 'Precious metals, coins and bars', feat_metals: 'Precious metals, coins and bars',
unlimited: 'unlimited', unlimited: 'unlimited',
dropHere: 'Drag the license file here',
dropOr: 'or click to choose one',
dropTooBig: 'That file is far too large for a license. The license file is a small JSON file of a '
+ 'few lines at most.',
installOk: 'License saved. The app is now the full version.', installOk: 'License saved. The app is now the full version.',
installOkFuture: 'License saved, but it does not cover this major version. The app stays in the free ' installOkFuture: 'License saved, but it does not cover this major version. The app stays in the free '
+ 'version for now.', + 'version for now.',
@@ -332,14 +320,14 @@ export const en = {
nwcRelayRefusedNoReason: 'The relay refuses the request and does not say why.', nwcRelayRefusedNoReason: 'The relay refuses the request and does not say why.',
nwcDecryptFailed: 'Decrypting the response failed: {reden}', nwcDecryptFailed: 'Decrypting the response failed: {reden}',
kindMarket: 'Atreides Services', kindMarket: 'Atreides Services',
usedByMarket: "The app's own price service, for prices, the license and support. Address and token apply to the whole installation, so to every portfolio. The app talks to the service through its own server; the token stays server-side and never comes back to the browser.", usedByMarket: "The app's own price service, for prices, the license and support. Address and token apply to the whole installation, so to every portfolio.",
marketConfigure: 'Configure', marketConfigure: 'Configure',
marketDialogTitle: 'Configure Atreides Services', marketDialogTitle: 'Configure Atreides Services',
marketTokenUnset: 'no token', marketTokenUnset: 'no token',
marketTokenLicense: 'token from the license ({tier})', marketTokenLicense: 'Token from the license ({tier})',
marketTokenManual: 'custom token', marketTokenManual: 'Custom token',
marketTokenPending: 'the app requests a token with the license', marketTokenPending: 'The app requests a token with the license',
marketTokenNoLicense: 'no license to request a token with', marketTokenNoLicense: 'No license to request a token with',
marketOverrideUrl: 'development address', marketOverrideUrl: 'development address',
marketTestOk: '{app} {version} · {n} instruments', marketTestOk: '{app} {version} · {n} instruments',
marketSpot: 'What the app fetches from the service', marketSpot: 'What the app fetches from the service',
@@ -436,14 +424,14 @@ export const en = {
addNoFit: 'Not enough space here for the remaining widgets', addNoFit: 'Not enough space here for the remaining widgets',
addNone: 'Everything has been added', addNone: 'Everything has been added',
profile: 'Layout', profile: 'Layout',
profileNew: 'New layout…', profileNew: 'New layout',
profileNewPrompt: 'Name of the new layout', profileNewPrompt: 'Name of the new layout',
profileRename: 'Rename…', profileRename: 'Rename',
profileRenamePrompt: 'New name for this layout', profileRenamePrompt: 'New name for this layout',
profileDelete: 'Delete this layout…', profileDelete: 'Delete this layout',
profileDeleteConfirm: 'Delete this layout and all its widgets? The other layouts remain.', profileDeleteConfirm: 'Delete this layout and all its widgets? The other layouts remain.',
profileSystemHint: 'The default layout cannot be renamed.', profileSystemHint: 'The default layout cannot be renamed.',
profileRestart: 'Start default layout over…', profileRestart: 'Start default layout over',
profileRestartConfirm: 'Start the default layout over? What you placed yourself is removed and the default layout takes its place.', profileRestartConfirm: 'Start the default layout over? What you placed yourself is removed and the default layout takes its place.',
nameRequired: 'A layout needs a name.', nameRequired: 'A layout needs a name.',
unknownProfile: 'Layout #{id} does not exist.', unknownProfile: 'Layout #{id} does not exist.',
@@ -610,7 +598,7 @@ export const en = {
kindSourceChoice: 'Source choice', kindSourceChoice: 'Source choice',
sourceFirst: 'first', sourceFirst: 'first',
sourceRemove: 'remove', sourceRemove: 'remove',
sourceChoiceNote: 'You can see the chain as it stands now on Integrations, Source choice, or on the Source tab of Price history.', sourceChoiceNote: 'You can see the chain as it stands now on Integrations, Source choice, or on the Prices tab of Price history.',
unknownKind: 'Unknown kind of proposal', unknownKind: 'Unknown kind of proposal',
ledger: 'ledger', ledger: 'ledger',
diff: 'difference', diff: 'difference',
@@ -928,7 +916,7 @@ export const en = {
finish: 'Set up', finish: 'Set up',
persona_bitcoin: 'Bitcoin only', persona_bitcoin: 'Bitcoin only',
persona_broad: 'Broader', persona_broad: 'Broader',
rerun: 'Set up…', rerun: 'Set up',
}, },
notes: { notes: {
tab: 'Notes', tab: 'Notes',
@@ -1236,9 +1224,7 @@ export const en = {
fiatHint: 'Traditional currency', fiatHint: 'Traditional currency',
btcHint: 'Base unit · max 21 million', btcHint: 'Base unit · max 21 million',
hierarchy: 'Unit hierarchy', hierarchy: 'Unit hierarchy',
satNote: 'The satoshi is the smallest on-chain unit. The millisatoshi exists only on the Lightning Network.', satNote: 'The satoshi is the smallest on-chain unit. The millisatoshi exists only on the Lightning Network.', },
footer: 'Live price from the selected price source',
},
labels: { labels: {
title: 'Labels', title: 'Labels',
@@ -1301,6 +1287,7 @@ export const en = {
merge: 'Merge', merge: 'Merge',
mergeTitle: 'Merge two accounts into one', mergeTitle: 'Merge two accounts into one',
mergeNeedTwo: 'You need at least two accounts to merge.', mergeNeedTwo: 'You need at least two accounts to merge.',
mergePaid: 'Merging accounts is part of the full edition.',
mergePick: '(choose an account)', mergePick: '(choose an account)',
mergeKeep: 'Account to keep', mergeKeep: 'Account to keep',
mergeDrop: 'Account to remove', mergeDrop: 'Account to remove',
@@ -1544,7 +1531,6 @@ export const en = {
}, },
valuations: { valuations: {
maxFree: 'The free version is limited to {max} balance. A license removes that limit; nothing is removed in the meantime.',
title: 'Balances', title: 'Balances',
needAsset: 'Create an asset first or migrate the data on the overview.', needAsset: 'Create an asset first or migrate the data on the overview.',
colDate: 'Reference date', colDate: 'Reference date',
@@ -1979,7 +1965,6 @@ export const en = {
provToGaps: 'Go to this gap under Gaps', provToGaps: 'Go to this gap under Gaps',
provSpotNote: 'up to {pct} off the closing price', provSpotNote: 'up to {pct} off the closing price',
provCarriedNote: 'up to {n} days old', provCarriedNote: 'up to {n} days old',
tabSource: 'Source',
diagCard_noCoverage: 'No integration provides prices', diagCard_noCoverage: 'No integration provides prices',
diagCard_allInactive: '{namen} is turned off', diagCard_allInactive: '{namen} is turned off',
diagCard_noChoice: 'No source selected', diagCard_noChoice: 'No source selected',
@@ -2146,58 +2131,72 @@ export const en = {
archiveFull: 'The archive is full of folders named "{naam}"; clean up a few first.', archiveFull: 'The archive is full of folders named "{naam}"; clean up a few first.',
notInArchive: 'There is no "{naam}" in the archive.', notInArchive: 'There is no "{naam}" in the archive.',
passwordRequired: 'Enter a password.', passwordRequired: 'Enter a password.',
intro: 'Each portfolio is a separate set of records: its own database and its own secrets, in its own folder. Exactly one is always open.', intro: 'Each portfolio is a separate set of records: its own database and its own secrets, in its own folder. Exactly one is always open; its card comes first.',
openNow: 'Open now', openNow: 'Open now',
activate: 'Open…', activate: 'Open',
activateConfirm: 'Switch to "{naam}"? The app restarts to do this.', activateConfirm: 'Switch to "{naam}"? The app restarts to do this.',
activateWaiting: 'The app is restarting…', activateWaiting: 'The app is restarting…',
activateNoSupervisor: 'The choice is saved, but this app was started without the supervisor (npm run start:server). Close the app and start it again to work in "{naam}".', activateNoSupervisor: 'The choice is saved, but this app was started without the supervisor (npm run start:server). Close the app and start it again to work in "{naam}".',
create: 'New portfolio…', create: 'New portfolio',
createConfirm: 'Create',
createName: 'Name', createName: 'Name',
createNameHint: 'The name becomes the folder name on disk.', createNameHint: 'The name becomes the folder name on disk.',
createCurrency: 'Base currency', createCurrency: 'Base currency',
createDone: 'Portfolio "{naam}" created.', createDone: 'Portfolio "{naam}" created.',
copy: 'Copy…', copy: 'Copy',
copyTitle: 'Copy of "{naam}"', copyTitle: 'Copy of "{naam}"',
copyName: 'Name of the copy', copyName: 'Name of the copy',
copyConfirm: 'Copy', copyConfirm: 'Copy',
copyDone: 'Portfolio "{naam}" created as a copy of "{bron}".', copyDone: 'Portfolio "{naam}" created as a copy of "{bron}".',
createHint: 'New portfolio gives fresh records without connections. You copy a portfolio from the list; a copy takes the database and the secrets along together, because a secret belongs to a row and only fits that database.', createHint: 'New portfolio gives fresh records without connections. You copy a portfolio through the menu (…) on its card; a copy takes the database and the secrets along together, because a secret belongs to a row and only fits that database.',
createCurrencyHint: 'The base currency is the currency the portfolio calculates in and shows all amounts in. You choose it for a new portfolio and after that it is fixed; a copy keeps the currency of the original.', createCurrencyHint: 'The base currency is the currency the portfolio calculates in and shows all amounts in. You choose it for a new portfolio and after that it is fixed; a copy keeps the currency of the original.',
rename: 'Rename…', rename: 'Rename',
renameTo: 'New name', renameTo: 'New name',
renameDone: 'Renamed to "{naam}".', renameDone: 'Renamed to "{naam}".',
renameActive: 'The portfolio that is open now cannot be renamed: its database is open. Switch to another one first.', renameActive: 'The portfolio that is open now cannot be renamed: its database is open. Switch to another one first.',
remove: 'Delete…', remove: 'Delete',
removeConfirm: 'Delete portfolio "{naam}"? The folder moves to the archive; nothing is erased. Type the name to confirm.', removeConfirm: 'Delete portfolio "{naam}"? The folder moves to the archive; nothing is erased. Type the name to confirm.',
removeTypeLabel: 'Type the name', removeTypeLabel: 'Type the name',
removeMismatch: 'That is not the name; nothing was changed.', removeMismatch: 'That is not the name; nothing was changed.',
removeDone: '"{naam}" is now in the archive.', removeDone: '"{naam}" is now in the archive.',
empty: 'There is no other portfolio yet.',
noDatabase: 'no database yet', noDatabase: 'no database yet',
lastUsed: 'last used',
never: 'not used yet', never: 'not used yet',
cardSince: '{munt}, since {jaar}',
factAssets: 'Assets in use',
factAccounts: 'Active accounts',
factEvents: 'Entries',
factValuations: 'Balances',
factLastUsed: 'Last used',
factBackup: 'Last backup',
backupNever: 'never',
more: 'More actions',
backupTitle: 'Backup',
backupLateHint: 'The last backup turns orange when it is older than {dagen} days or none was ever made. Restore and start empty sit in the menu (…) of the portfolio that is open and apply only to it.',
archiveCount: 'Archive ({n})',
demoBadge: 'Example',
demoHint: 'Demo is example records with bitcoin, an exchange, a wallet of your own and a bank account, filled over the past two and a half years. It does not count towards the limit of the free edition. Opening it is a restart; so is going back to your own portfolio.',
archiveTitle: 'Archive', archiveTitle: 'Archive',
archiveIntro: 'Deleted portfolios are kept here. They no longer count anywhere, but the files still exist.', archiveIntro: 'Deleted portfolios are kept here. They no longer count anywhere, but the files still exist.',
archiveEmpty: 'The archive is empty.', purge: 'Delete permanently',
purge: 'Delete permanently…',
purgeConfirm: 'Permanently delete the folder of "{naam}" and everything in it? The database, secrets and history will be gone. This cannot be undone. Type the name to confirm.', purgeConfirm: 'Permanently delete the folder of "{naam}" and everything in it? The database, secrets and history will be gone. This cannot be undone. Type the name to confirm.',
purgeDone: '"{naam}" has been permanently deleted.', purgeDone: '"{naam}" has been permanently deleted.',
settingsTitle: 'Settings of this portfolio', settingsButton: 'Settings',
settingsOf: 'Settings of {naam}',
hintTitle: 'What you see here', hintTitle: 'What you see here',
authTitle: 'Access to this portfolio', authTitle: 'Access to this portfolio',
authNone: 'No password set. The app can therefore only be reached from this machine.', authNone: 'No password set.',
authSet: 'A password is set.', authSet: 'A password is set.',
authEnv: 'Set through the environment variable ASSETAPP_PASSWORD. As long as that is set, it overrides what you enter here.', authEnv: 'Set through the environment variable ASSETAPP_PASSWORD. As long as that is set, it overrides what you enter here.',
authChange: 'Set or change…', authChange: 'Set password',
authClear: 'Clear…', authSave: 'Save',
authClear: 'Clear',
authPassword: 'Password', authPassword: 'Password',
authClearConfirm: 'Remove the password of this portfolio? After that the app can only be reached from this machine.', authClearConfirm: 'Remove the password of this portfolio? After that the app can only be reached from this machine.',
authDone: 'Saved. It applies from the next time the app starts.', authDone: 'Saved. It applies from the next time the app starts.',
authClearedDone: 'Removed. It applies from the next time the app starts.', authClearedDone: 'Removed. It applies from the next time the app starts.',
authLogout: 'Sign out', authLogout: 'Sign out',
authLogoutConfirm: 'Sign out? You need to enter the password again to continue.', authLogoutConfirm: 'Sign out? You need to enter the password again to continue.',
authHint: 'Access from another device requires a certificate and sign-in credentials. The certificate belongs to this installation; the credentials to this portfolio.', authHint: 'Without a password the app can only be reached from this machine. Access from another device requires a certificate and sign-in credentials. The certificate belongs to this installation; the credentials to this portfolio.',
startYearTitle: 'Start year of these records', startYearTitle: 'Start year of these records',
startYearLabel: 'Start reports at', startYearLabel: 'Start reports at',
startYearAuto: 'Automatic (from the first entry)', startYearAuto: 'Automatic (from the first entry)',
@@ -2214,7 +2213,6 @@ export const en = {
mwrHint: 'This changes the percentages on the taxes page. Modified Dietz always gives a figure; IRR is more accurate but does not always have a solution, for example with a position that drops to zero in between. The chosen method is always shown with the figure.', mwrHint: 'This changes the percentages on the taxes page. Modified Dietz always gives a figure; IRR is more accurate but does not always have a solution, for example with a position that drops to zero in between. The chosen method is always shown with the figure.',
}, },
dbfile: { dbfile: {
title: 'All records',
passTooShort: 'Choose a password of at least 8 characters for the backup.', passTooShort: 'Choose a password of at least 8 characters for the backup.',
notABackup: 'This file is not an AssetApp backup (too short).', notABackup: 'This file is not an AssetApp backup (too short).',
notABackupUseRestore: 'This file is not an AssetApp backup. You restore a separate .sqlite file with "Restore".', notABackupUseRestore: 'This file is not an AssetApp backup. You restore a separate .sqlite file with "Restore".',
@@ -2231,20 +2229,21 @@ export const en = {
versionNewerThanApp: 'This database is version {versie} and therefore newer than the app ({app}). Update the app before you restore it.', versionNewerThanApp: 'This database is version {versie} and therefore newer than the app ({app}). Update the app before you restore it.',
emptyFile: 'Empty file received.', emptyFile: 'Empty file received.',
notSqlite: 'This is not an SQLite file (the file header does not match).', notSqlite: 'This is not an SQLite file (the file header does not match).',
backup: 'Back up…', backup: 'Backup',
backupConfirm: 'Back up',
backupPass: 'Password for the backup', backupPass: 'Password for the backup',
backupPassAgain: 'Repeat password', backupPassAgain: 'Repeat password',
backupPassMismatch: 'The two passwords do not match.', backupPassMismatch: 'The two passwords do not match.',
backupDone: 'Backup created. Keep the password safe: without the password the backup cannot be restored.', backupDone: 'Backup created. Keep the password safe: without the password the backup cannot be restored.',
export: 'Export database', export: 'Export database',
import: 'Restore…', import: 'Restore',
confirmRestoreBackup: 'Restore this backup? This replaces the database AND the secrets (secrets.json in the folder of this portfolio). The previous version of both is kept as .previous.', confirmRestoreBackup: 'Restore this backup? This replaces the database AND the secrets (secrets.json in the folder of this portfolio). The previous version of both is kept as .previous.',
restoredSecrets: 'Secrets restored as well:', restoredSecrets: 'Secrets restored as well:',
confirmImport: 'Replace the current database with this file? The previous one is kept as .previous next to the database file.', confirmImport: 'Replace the current database with this file? The previous one is kept as .previous next to the database file.',
importDone: 'Database restored', importDone: 'Database restored',
importOrphans: 'Note, orphaned references found:', importOrphans: 'Note, orphaned references found:',
hint: 'The backup contains the database and the secrets, encrypted. The export is only the database: enough to read, not enough to restore everything.', hint: 'The backup contains the database and the secrets, encrypted. The export is only the database: enough to read, not enough to restore everything.',
fresh: 'Start empty…', fresh: 'Start empty',
confirmFresh: 'Start with empty records? A fresh database is put into use; the current one is kept as .previous next to the database file. Make a backup first if you might ever need it again.', confirmFresh: 'Start with empty records? A fresh database is put into use; the current one is kept as .previous next to the database file. Make a backup first if you might ever need it again.',
freshWord: 'empty', freshWord: 'empty',
freshTypeLabel: 'Type the word to confirm', freshTypeLabel: 'Type the word to confirm',
@@ -2266,7 +2265,7 @@ export const en = {
nSkipped: 'skipped', nSkipped: 'skipped',
account: 'Account (exchange) for this import', account: 'Account (exchange) for this import',
chooseAccount: '(choose account)', chooseAccount: '(choose account)',
newAccount: '+ new exchange account…', newAccount: '+ new exchange account',
newAccountPh: 'Name of new account', newAccountPh: 'Name of new account',
assetsTitle: 'Assets', assetsTitle: 'Assets',
assetOk: 'linked', assetOk: 'linked',
+100 -103
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:f025d672341c54d23ebad8e10876b424ac570cd500126582bf4b9c905f2cd672 // Generated, do not edit. sha256:68408d2148a830c2e11f8761e94fa494e4d87a9e45166891d38c4b2464170782
export const nl = { export const nl = {
app: { name: 'Atreides Assets' }, app: { name: 'Atreides Assets' },
@@ -11,6 +11,8 @@ export const nl = {
requestTooLarge: 'Het verzoek is te groot.', requestTooLarge: 'Het verzoek is te groot.',
fileTooLarge: 'Het bestand is te groot.', fileTooLarge: 'Het bestand is te groot.',
invalidAmount: 'Ongeldige hoeveelheid.', invalidAmount: 'Ongeldige hoeveelheid.',
create: 'Aanmaken',
rename: 'Hernoemen',
}, },
splash: { splash: {
@@ -70,22 +72,15 @@ export const nl = {
'why_unknown-version': 'De versie van de app is niet te bepalen', 'why_unknown-version': 'De versie van de app is niet te bepalen',
why_licensed: 'Geldige licentie', why_licensed: 'Geldige licentie',
cardTitle: 'Licentie', cardTitle: 'Licentie',
cardFree: 'Deze installatie draait de gratis versie: onbeperkt in tijd, met een kleine ' cardFree: 'Gratis versie: onbeperkt in tijd, met een kleine administratie. Er wordt nooit iets '
+ 'administratie en zonder de koppelingen, de bronkeuze en de belastingpagina. Lezen en ' + 'weggehaald.',
+ 'exporteren blijven altijd werken; er wordt nooit iets weggehaald.', cardFull: 'Volledige versie: alle grenzen opgeheven.',
cardBuy: 'De betaalde versie heft die grenzen op. Hebt u een licentiebestand, zet het dan hieronder ' cardFor: 'Deze licentie is voor {npub}',
+ 'neer.', cardFacts: 'Afgegeven op {issued}, geldig voor hoofdversie {from} tot en met {to}.',
cardFull: 'Deze installatie draait de volledige versie. Alle grenzen zijn opgeheven en elk '
+ 'onderdeel staat open.',
cardDevelop: 'Dit is een ontwikkelversie: onder 1.0.0 beperkt de app niets.',
cardFacts: 'De licentie is voor {buyer}, afgegeven op {issued} en geldig voor hoofdversie {from} '
+ 'tot en met {to}.',
cardOutOfRange: 'Deze app is versie {version} en valt buiten dat bereik.',
cardProblem: 'Er staat een licentiebestand, maar de app kan er niets mee.', cardProblem: 'Er staat een licentiebestand, maar de app kan er niets mee.',
cardVersion: 'Versie {version}', cardVersion: 'Versie {version}',
signInTitle: 'Aanmelden', signInTitle: 'Aanmelden',
signInIntro: 'Uw licentie hoort bij een nostr-sleutel. Meld u aan om de licenties bij die sleutel op ' signInIntro: 'Meld u aan met uw nostr-sleutel om te kopen of uw licentie op te halen.',
+ 'te halen.',
signInLast: 'Laatst aangemeld als {npub}.', signInLast: 'Laatst aangemeld als {npub}.',
signInExtension: 'Met extensie', signInExtension: 'Met extensie',
signInExtensionNone: 'Geen nostr-extensie gevonden in deze browser (zoals Alby of nos2x).', signInExtensionNone: 'Geen nostr-extensie gevonden in deze browser (zoals Alby of nos2x).',
@@ -126,7 +121,6 @@ export const nl = {
buyClosed: 'De verkoop is op dit moment gesloten.', buyClosed: 'De verkoop is op dit moment gesloten.',
buyAway: 'Atreides Services is nu niet bereikbaar, dus kopen kan op dit moment niet. Probeer het later ' buyAway: 'Atreides Services is nu niet bereikbaar, dus kopen kan op dit moment niet. Probeer het later '
+ 'opnieuw.', + 'opnieuw.',
buySignInFirst: 'Meld u eerst aan: de licentie komt op uw sleutel te staan.',
buyButton: 'Kopen', buyButton: 'Kopen',
buyInvoice: 'Betaal deze invoice met een Lightning-wallet. Hij is geldig tot {time}.', buyInvoice: 'Betaal deze invoice met een Lightning-wallet. Hij is geldig tot {time}.',
buyOpenWallet: 'Openen in wallet', buyOpenWallet: 'Openen in wallet',
@@ -183,8 +177,7 @@ export const nl = {
+ 'ID-controle).', + 'ID-controle).',
payNote: 'Stand van oktober 2026: controleer de voorwaarden bij de aanbieder.', payNote: 'Stand van oktober 2026: controleer de voorwaarden bij de aanbieder.',
nostrMore: 'Over nostr en uw sleutel', nostrMore: 'Over nostr en uw sleutel',
signInKnown: 'Uw licentie staat op {npub}. Onderteken met die sleutel om licenties op te halen, te kopen ' signInKnown: 'Gebruik de sleutel waar deze licentie op staat.',
+ 'of support te vragen; de npub alleen bewijst niet dat u het bent.',
infoNostrHead: 'Wat is nostr?', infoNostrHead: 'Wat is nostr?',
infoNostr: 'Nostr is een open protocol waarin uw identiteit een sleutelpaar is. De publieke sleutel ' infoNostr: 'Nostr is een open protocol waarin uw identiteit een sleutelpaar is. De publieke sleutel '
+ '(npub) is als een adres: die mag iedereen zien. De geheime sleutel (nsec) is als een wachtwoord dat ' + '(npub) is als een adres: die mag iedereen zien. De geheime sleutel (nsec) is als een wachtwoord dat '
@@ -211,17 +204,14 @@ export const nl = {
+ 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.', + 'gemaakt, zodat het alleen terug te vinden is als u zich er zelf mee meldt.',
name_fiatAccounts: 'Fiatrekeningen', name_fiatAccounts: 'Fiatrekeningen',
name_assets: 'Assets naast valuta', name_assets: 'Assets naast valuta',
name_valuations: 'Balansen', name_portfolios: "Portfolio's",
name_portfolios: 'Portefeuilles',
featTitle: 'Wat de edities bieden', featTitle: 'Wat de edities bieden',
featFull: 'Volledig', featFull: 'Volledig',
featFree: 'Gratis', featFree: 'Gratis',
featFreeOpen: 'Wat de gratis editie krijgt, ligt nog niet vast.',
featNoShot: 'Nog geen schermafdruk van dit onderdeel.', featNoShot: 'Nog geen schermafdruk van dit onderdeel.',
featIncluded: 'Inbegrepen', featIncluded: 'Inbegrepen',
featExcluded: 'Niet inbegrepen', featExcluded: 'Niet inbegrepen',
featUndecided: 'Nog niet bepaald', featUndecided: 'Nog niet bepaald',
featPartial: 'deels',
featPartialTitle: 'Een deel is inbegrepen', featPartialTitle: 'Een deel is inbegrepen',
featGroup_overview: 'Overzicht en rapportage', featGroup_overview: 'Overzicht en rapportage',
featGroup_bitcoin: 'Bitcoin en Lightning', featGroup_bitcoin: 'Bitcoin en Lightning',
@@ -238,22 +228,19 @@ export const nl = {
feat_balances: 'Balansen en reconcile', feat_balances: 'Balansen en reconcile',
feat_ownMempool: 'Eigen mempool-node', feat_ownMempool: 'Eigen mempool-node',
feat_priceHistory: 'Prijsgeschiedenis', feat_priceHistory: 'Prijsgeschiedenis',
feat_sourceChoice: 'Bronkeuze per asset', feat_sourceChoice: 'Bronkeuze-matrix',
feat_providerKeys: 'Eigen koersbronnen en API-sleutels', feat_providerKeys: 'Beheer van eigen integraties en API-sleutels',
feat_signals: 'Bitcoin-signalen, grafieken en widget', feat_signals: 'Bitcoin-signalen, grafieken en widget',
feat_netshare: 'Widget netwerkaandeel', feat_netshare: 'Widget netwerkaandeel',
feat_portfolios: 'Meerdere portefeuilles', feat_portfolios: "Meerdere portfolio's",
feat_ai: 'AI-koppeling (MCP)', feat_ai: 'AI-koppeling (MCP)',
feat_accountMerge: 'Accounts samenvoegen',
feat_import: 'Importeren en exporteren', feat_import: 'Importeren en exporteren',
feat_data: 'Data: de ruwe tabellen', feat_data: 'Data: de ruwe tabellen',
feat_health: 'Gezondheid: controles op de administratie', feat_health: 'Gezondheid: controles op de administratie',
feat_kiosk: 'De app in een eigen venster', feat_kiosk: 'De app in een eigen venster',
feat_metals: 'Edelmetaal, munten en baren', feat_metals: 'Edelmetaal, munten en baren',
unlimited: 'onbeperkt', unlimited: 'onbeperkt',
dropHere: 'Sleep het licentiebestand hierheen',
dropOr: 'of klik om er een te kiezen',
dropTooBig: 'Dat bestand is veel te groot voor een licentie. Het licentiebestand is een klein '
+ 'JSON-bestand van hooguit een paar regels.',
installOk: 'Licentie opgeslagen. De app is nu de volledige versie.', installOk: 'Licentie opgeslagen. De app is nu de volledige versie.',
installOkFuture: 'Licentie opgeslagen, maar hij geldt niet voor deze hoofdversie. De app blijft ' installOkFuture: 'Licentie opgeslagen, maar hij geldt niet voor deze hoofdversie. De app blijft '
+ 'voorlopig in de gratis stand.', + 'voorlopig in de gratis stand.',
@@ -331,14 +318,14 @@ export const nl = {
nwcRelayRefusedNoReason: 'De relay weigert het verzoek en zegt niet waarom.', nwcRelayRefusedNoReason: 'De relay weigert het verzoek en zegt niet waarom.',
nwcDecryptFailed: 'Antwoord ontsleutelen mislukt: {reden}', nwcDecryptFailed: 'Antwoord ontsleutelen mislukt: {reden}',
kindMarket: 'Atreides Services', kindMarket: 'Atreides Services',
usedByMarket: 'De eigen koersdienst, voor koersen, de licentie en support. Adres en token gelden voor de hele installatie, dus voor elke portefeuille. De app praat via de eigen server met de dienst; het token blijft server-side en komt nooit terug in de browser.', usedByMarket: 'De eigen koersdienst, voor koersen, de licentie en support. Adres en token gelden voor de hele installatie, dus voor elk portfolio.',
marketConfigure: 'Instellen', marketConfigure: 'Instellen',
marketDialogTitle: 'Atreides Services instellen', marketDialogTitle: 'Atreides Services instellen',
marketTokenUnset: 'geen token', marketTokenUnset: 'geen token',
marketTokenLicense: 'token van de licentie ({tier})', marketTokenLicense: 'Token van de licentie ({tier})',
marketTokenManual: 'eigen token', marketTokenManual: 'Eigen token',
marketTokenPending: 'de app vraagt een token aan met de licentie', marketTokenPending: 'De app vraagt een token aan met de licentie',
marketTokenNoLicense: 'geen licentie om een token mee aan te vragen', marketTokenNoLicense: 'Geen licentie om een token mee aan te vragen',
marketOverrideUrl: 'ontwikkeladres', marketOverrideUrl: 'ontwikkeladres',
marketTestOk: '{app} {version} · {n} instrumenten', marketTestOk: '{app} {version} · {n} instrumenten',
marketSpot: 'Wat de app uit de dienst haalt', marketSpot: 'Wat de app uit de dienst haalt',
@@ -391,7 +378,7 @@ export const nl = {
system: 'Systeem', system: 'Systeem',
home: 'Dashboard', home: 'Dashboard',
settings: 'Instellingen', settings: 'Instellingen',
portfolios: 'Portefeuilles', portfolios: "Portfolio's",
import: 'Importeren en exporteren', import: 'Importeren en exporteren',
data: 'Data', data: 'Data',
health: 'Gezondheid', health: 'Gezondheid',
@@ -435,14 +422,14 @@ export const nl = {
addNoFit: 'Hier is te weinig ruimte voor de widgets die nog kunnen', addNoFit: 'Hier is te weinig ruimte voor de widgets die nog kunnen',
addNone: 'Alles is al toegevoegd', addNone: 'Alles is al toegevoegd',
profile: 'Indeling', profile: 'Indeling',
profileNew: 'Nieuwe indeling…', profileNew: 'Nieuwe indeling',
profileNewPrompt: 'Naam van de nieuwe indeling', profileNewPrompt: 'Naam van de nieuwe indeling',
profileRename: 'Naam wijzigen…', profileRename: 'Naam wijzigen',
profileRenamePrompt: 'Nieuwe naam voor deze indeling', profileRenamePrompt: 'Nieuwe naam voor deze indeling',
profileDelete: 'Deze indeling verwijderen…', profileDelete: 'Deze indeling verwijderen',
profileDeleteConfirm: 'Deze indeling en al zijn widgets verwijderen? De andere indelingen blijven staan.', profileDeleteConfirm: 'Deze indeling en al zijn widgets verwijderen? De andere indelingen blijven staan.',
profileSystemHint: 'Standaard kan niet hernoemd worden.', profileSystemHint: 'Standaard kan niet hernoemd worden.',
profileRestart: 'Standaard opnieuw beginnen…', profileRestart: 'Standaard opnieuw beginnen',
profileRestartConfirm: 'Standaard opnieuw beginnen? Wat je zelf hebt neergezet gaat weg en de standaardindeling komt ervoor in de plaats.', profileRestartConfirm: 'Standaard opnieuw beginnen? Wat je zelf hebt neergezet gaat weg en de standaardindeling komt ervoor in de plaats.',
nameRequired: 'Een indeling heeft een naam nodig.', nameRequired: 'Een indeling heeft een naam nodig.',
unknownProfile: 'Indeling #{id} bestaat niet.', unknownProfile: 'Indeling #{id} bestaat niet.',
@@ -598,12 +585,12 @@ export const nl = {
kindAccount: 'Nieuw account', kindAccount: 'Nieuw account',
kindAccountDelete: 'Account verwijderen', kindAccountDelete: 'Account verwijderen',
kindValuation: 'Balans', kindValuation: 'Balans',
kindPortfolio: 'Nieuwe portefeuille', kindPortfolio: 'Nieuw portfolio',
portfolioIn: 'in', portfolioIn: 'in',
portfolioNote: 'Leeg, en zonder sleutels of data uit deze administratie. Hij wordt niet geopend: ' portfolioNote: 'Leeg, en zonder sleutels of data uit deze administratie. Het wordt niet geopend: '
+ 'wisselen doe je zelf op Systeem → Portefeuilles, en dat is een herstart van de app.', + "wisselen doe je zelf op Systeem → Portfolio's, en dat is een herstart van de app.",
kindPreference: 'Instelling wijzigen', kindPreference: 'Instelling wijzigen',
preferenceNote: 'Deze instelling geldt voor deze portefeuille en verandert alleen de weergave of ' preferenceNote: 'Deze instelling geldt voor dit portfolio en verandert alleen de weergave of '
+ 'een interval, niet de administratie zelf.', + 'een interval, niet de administratie zelf.',
preferenceCurrent: 'nu', preferenceCurrent: 'nu',
preferenceOn: 'aan', preferenceOn: 'aan',
@@ -611,7 +598,7 @@ export const nl = {
kindSourceChoice: 'Bronkeuze', kindSourceChoice: 'Bronkeuze',
sourceFirst: 'als eerste', sourceFirst: 'als eerste',
sourceRemove: 'eruit halen', sourceRemove: 'eruit halen',
sourceChoiceNote: 'De keten zoals hij nu staat zie je op Integraties, Bronkeuze, of op het tabblad Bron van Prijsgeschiedenis.', sourceChoiceNote: 'De keten zoals hij nu staat zie je op Integraties, Bronkeuze, of op het tabblad Koersen van Prijsgeschiedenis.',
unknownKind: 'Onbekend soort voorstel', unknownKind: 'Onbekend soort voorstel',
ledger: 'grootboek', ledger: 'grootboek',
diff: 'verschil', diff: 'verschil',
@@ -669,7 +656,7 @@ export const nl = {
bookTitle: 'Reservering boeken', bookTitle: 'Reservering boeken',
otherTo: 'Naar', otherTo: 'Naar',
otherFrom: 'Van', otherFrom: 'Van',
outside: 'Buiten de portefeuille', outside: 'Buiten het portfolio',
fill: 'Vul een datum, een asset en een bedrag groter dan nul in.', fill: 'Vul een datum, een asset en een bedrag groter dan nul in.',
confirmSettle: 'Deze reservering afhandelen zonder boeking? Hij verdwijnt dan.', confirmSettle: 'Deze reservering afhandelen zonder boeking? Hij verdwijnt dan.',
confirmDelete: 'Deze reservering verwijderen?', confirmDelete: 'Deze reservering verwijderen?',
@@ -937,7 +924,7 @@ export const nl = {
finish: 'Inrichten', finish: 'Inrichten',
persona_bitcoin: 'Alleen bitcoin', persona_bitcoin: 'Alleen bitcoin',
persona_broad: 'Breder', persona_broad: 'Breder',
rerun: 'Inrichten…', rerun: 'Inrichten',
}, },
notes: { notes: {
@@ -1023,8 +1010,8 @@ export const nl = {
resultSignsNegative: 'Alleen negatief', resultSignsNegative: 'Alleen negatief',
resultSignsBoth: 'Negatief en positief', resultSignsBoth: 'Negatief en positief',
resultSignsHint: 'Geldt voor resultaten en rendementen in groen of rood. Bij "Geen" zegt alleen de kleur of het winst of verlies is. Zonder kleur staat het teken er altijd.', resultSignsHint: 'Geldt voor resultaten en rendementen in groen of rood. Bij "Geen" zegt alleen de kleur of het winst of verlies is. Zonder kleur staat het teken er altijd.',
persona: 'Portefeuille gaat over', persona: 'Portfolio gaat over',
personaHint: 'Breder verbergt Signalen & Grafieken en de bitcoinwidgets uit de keuze voor het dashboard. Per portefeuille; boekingen en assets veranderen niet.', personaHint: 'Breder verbergt Signalen & Grafieken en de bitcoinwidgets uit de keuze voor het dashboard. Per portfolio; boekingen en assets veranderen niet.',
kiosk: 'Openen in', kiosk: 'Openen in',
kioskWindow: 'Browser', kioskWindow: 'Browser',
kioskFull: 'Eigen venster', kioskFull: 'Eigen venster',
@@ -1076,13 +1063,13 @@ export const nl = {
writeOn: 'Aan: een AI mag gegevens toevoegen of wijzigen, altijd via een voorstel dat u accordeert.', writeOn: 'Aan: een AI mag gegevens toevoegen of wijzigen, altijd via een voorstel dat u accordeert.',
writeOff: 'Uit: elk verzoek om iets te wijzigen wordt geweigerd.', writeOff: 'Uit: elk verzoek om iets te wijzigen wordt geweigerd.',
rawLabel: 'Onbewerkt delen', rawLabel: 'Onbewerkt delen',
rawOn: 'Aan: een AI ziet de echte namen en bedragen van deze portefeuille.', rawOn: 'Aan: een AI ziet de echte namen en bedragen van dit portfolio.',
rawOff: 'Uit: een AI ziet verwijzingen en marges in plaats van namen en bedragen.', rawOff: 'Uit: een AI ziet verwijzingen en marges in plaats van namen en bedragen.',
rawHint: 'Alleen aanzetten als deze portefeuille geen echte gegevens bevat. De app kan dat niet ' rawHint: 'Alleen aanzetten als dit portfolio geen echte gegevens bevat. De app kan dat niet '
+ 'vaststellen; dat weet alleen u. Let op: een kopie van een portefeuille bevat de gegevens van ' + 'vaststellen; dat weet alleen u. Let op: een kopie van een portfolio bevat de gegevens van '
+ 'het origineel.', + 'het origineel.',
rawConfirm: 'Onbewerkt delen aanzetten voor "{naam}"? Een gekoppelde AI ziet daarna de echte namen ' rawConfirm: 'Onbewerkt delen aanzetten voor "{naam}"? Een gekoppelde AI ziet daarna de echte namen '
+ 'en bedragen van deze portefeuille in plaats van verwijzingen en marges. Doe dit alleen als hier ' + 'en bedragen van dit portfolio in plaats van verwijzingen en marges. Doe dit alleen als hier '
+ 'geen echte gegevens in staan.', + 'geen echte gegevens in staan.',
precisionTitle: 'Hoe precies een AI de cijfers ziet', precisionTitle: 'Hoe precies een AI de cijfers ziet',
precisionIntro: 'Aan betekent marges in plaats van exacte getallen, en dat is de stand waarin de ' precisionIntro: 'Aan betekent marges in plaats van exacte getallen, en dat is de stand waarin de '
@@ -1103,7 +1090,7 @@ export const nl = {
precisionCombo: 'Let op: staan "Percentages" en "Totalen" allebei uit, dan laat een AI-antwoord de ' precisionCombo: 'Let op: staan "Percentages" en "Totalen" allebei uit, dan laat een AI-antwoord de '
+ 'verdeling over uw assets wég. Samen leggen die twee de waarde per asset vast, terwijl de ' + 'verdeling over uw assets wég. Samen leggen die twee de waarde per asset vast, terwijl de '
+ 'bedragen per regel juist in marges staan.', + 'bedragen per regel juist in marges staan.',
scope: 'Deze instellingen gelden alleen voor de portefeuille die nu open is: {naam}.', scope: 'Deze instellingen gelden alleen voor het portfolio dat nu open is: {naam}.',
revoke: 'Een recht dat u geeft, kunt u hier altijd weer intrekken: zet de schuif uit en het geldt ' revoke: 'Een recht dat u geeft, kunt u hier altijd weer intrekken: zet de schuif uit en het geldt '
+ 'vanaf de volgende aanroep, ook voor een AI-programma dat al draait. Herstarten is niet nodig. ' + 'vanaf de volgende aanroep, ook voor een AI-programma dat al draait. Herstarten is niet nodig. '
+ 'Wat al is goedgekeurd, blijft staan; intrekken gaat over wat een AI hierna nog mag.', + 'Wat al is goedgekeurd, blijft staan; intrekken gaat over wat een AI hierna nog mag.',
@@ -1252,9 +1239,7 @@ export const nl = {
fiatHint: 'Traditionele munteenheid', fiatHint: 'Traditionele munteenheid',
btcHint: 'Basiseenheid · max 21 mln', btcHint: 'Basiseenheid · max 21 mln',
hierarchy: 'Eenheden hiërarchie', hierarchy: 'Eenheden hiërarchie',
satNote: 'De satoshi is de kleinste on-chain eenheid. De millisatoshi bestaat alleen op het Lightning Network.', satNote: 'De satoshi is de kleinste on-chain eenheid. De millisatoshi bestaat alleen op het Lightning Network.', },
footer: 'Live koers via de gekozen koersbron',
},
labels: { labels: {
title: 'Labels', title: 'Labels',
@@ -1317,6 +1302,7 @@ export const nl = {
merge: 'Samenvoegen', merge: 'Samenvoegen',
mergeTitle: 'Twee accounts samenvoegen tot één', mergeTitle: 'Twee accounts samenvoegen tot één',
mergeNeedTwo: 'Je hebt minstens twee accounts nodig om samen te voegen.', mergeNeedTwo: 'Je hebt minstens twee accounts nodig om samen te voegen.',
mergePaid: 'Accounts samenvoegen hoort bij de volledige editie.',
mergePick: '(kies een account)', mergePick: '(kies een account)',
mergeKeep: 'Behouden account', mergeKeep: 'Behouden account',
mergeDrop: 'Opheffen account', mergeDrop: 'Opheffen account',
@@ -1497,7 +1483,7 @@ export const nl = {
buitenBox3: 'Buiten het overzicht gehouden (telt wél als vermogen):', buitenBox3: 'Buiten het overzicht gehouden (telt wél als vermogen):',
emptyColsNote: 'Weggelaten peildata omdat er niets stond: {jaren}. In het resultaat per jaar blijven ' emptyColsNote: 'Weggelaten peildata omdat er niets stond: {jaren}. In het resultaat per jaar blijven '
+ 'ze de beginwaarde van dat jaar.', + 'ze de beginwaarde van dat jaar.',
startYearNote: 'Deze administratie begint in {eerste} en wordt getoond vanaf peildatum 1 januari {start}. De standen tellen alles daarvóór gewoon mee; er ontbreken alleen kolommen. Het startjaar staat op Systeem → Portefeuilles.', startYearNote: 'Deze administratie begint in {eerste} en wordt getoond vanaf peildatum 1 januari {start}. De standen tellen alles daarvóór gewoon mee; er ontbreken alleen kolommen. Het startjaar staat op Systeem → Portfolio\'s.',
aangifteNote: 'Kengetallen per jaar over het vermogen dat in deze app staat: crypto, edelmetaal en de accounts die je hier bijhoudt. Rendement = eindwaarde - beginwaarde - aankopen + verkopen - wat uit balansen kwam. "Huidige waarde" is het lopende jaar t/m nu; een jaar krijgt een eigen kolom zodra het is afgerond. De kop van een jaarkolom is de peildatum waarop dat jaar eindigt, zodat de kolommen uitlijnen met de tabel hierboven: de Eindwaarde hier is dezelfde peildatum-kolom daar, en de Beginwaarde is de kolom rechts ernaast. Klik een bedrag om het (afgerond op hele eenheden) te kopiëren. Deze app rekent geen belasting uit: dit zijn de kengetallen die een aangifte doorgaans vraagt; tarief en vrijstellingen verschillen per land.', aangifteNote: 'Kengetallen per jaar over het vermogen dat in deze app staat: crypto, edelmetaal en de accounts die je hier bijhoudt. Rendement = eindwaarde - beginwaarde - aankopen + verkopen - wat uit balansen kwam. "Huidige waarde" is het lopende jaar t/m nu; een jaar krijgt een eigen kolom zodra het is afgerond. De kop van een jaarkolom is de peildatum waarop dat jaar eindigt, zodat de kolommen uitlijnen met de tabel hierboven: de Eindwaarde hier is dezelfde peildatum-kolom daar, en de Beginwaarde is de kolom rechts ernaast. Klik een bedrag om het (afgerond op hele eenheden) te kopiëren. Deze app rekent geen belasting uit: dit zijn de kengetallen die een aangifte doorgaans vraagt; tarief en vrijstellingen verschillen per land.',
kgRendement: 'Rendement', kgRendement: 'Rendement',
kgUitBalansen: 'Uit balansen (niet geboekt)', kgUitBalansen: 'Uit balansen (niet geboekt)',
@@ -1555,14 +1541,12 @@ export const nl = {
reservation: 'reservering', reservation: 'reservering',
interestDue: 'rente verwacht', interestDue: 'rente verwacht',
}, },
hintActivity: 'De lijn bovenaan (alle getoonde accounts), onderaan een kaart en in het accountvenster is de activiteit vanaf het startjaar van de portefeuille: een piek omhoog is een dag waarop er iets binnenkwam, een dal een dag waarop er iets uitging; de hoogte volgt het aantal boekingen. Streepjes zijn balansen, rente-afspraken, verwachte rentebijschrijvingen, reserveringen (een vierkantje) en wijzigingen aan het account; de lijn loopt door tot de laatste daarvan, ook als die in de toekomst ligt. In het accountvenster opent een klik op een dag die boeking of reservering.', hintActivity: 'De lijn bovenaan (alle getoonde accounts), onderaan een kaart en in het accountvenster is de activiteit vanaf het startjaar van het portfolio: een piek omhoog is een dag waarop er iets binnenkwam, een dal een dag waarop er iets uitging; de hoogte volgt het aantal boekingen. Streepjes zijn balansen, rente-afspraken, verwachte rentebijschrijvingen, reserveringen (een vierkantje) en wijzigingen aan het account; de lijn loopt door tot de laatste daarvan, ook als die in de toekomst ligt. In het accountvenster opent een klik op een dag die boeking of reservering.',
hintInterest: 'Onder het saldo: de lopende rente-afspraken van het account en hoe vaak er rente of beloning is geboekt.', hintInterest: 'Onder het saldo: de lopende rente-afspraken van het account en hoe vaak er rente of beloning is geboekt.',
hintCapture: 'De knop "Saldo vastleggen" staat standaard alleen bij geld. Per asset zet je hem aan of uit op Systeem, tab Assets, kolom "Saldo vastleggen".', hintCapture: 'De knop "Saldo vastleggen" staat standaard alleen bij geld. Per asset zet je hem aan of uit op Systeem, tab Assets, kolom "Saldo vastleggen".',
}, },
valuations: { valuations: {
maxFree: 'De gratis versie houdt het op {max} balans. Met een licentie vervalt die grens; '
+ 'er wordt intussen niets weggehaald.',
title: 'Balansen', title: 'Balansen',
needAsset: 'Maak eerst een asset aan of migreer de data op het overzicht.', needAsset: 'Maak eerst een asset aan of migreer de data op het overzicht.',
colDate: 'Peildatum', colDate: 'Peildatum',
@@ -1593,8 +1577,8 @@ export const nl = {
acceptedHint: 'Aanvaard verschil, klik om terug te draaien:', acceptedHint: 'Aanvaard verschil, klik om terug te draaien:',
bookTitle: 'Verschil boeken', bookTitle: 'Verschil boeken',
bookAsSuffix: 'boeken als', bookAsSuffix: 'boeken als',
bookAsSell: 'Verkoop (ging uit je portefeuille)', bookAsSell: 'Verkoop (ging uit je portfolio)',
bookAsBuy: 'Aankoop (kwam je portefeuille in)', bookAsBuy: 'Aankoop (kwam je portfolio in)',
bookAsTransferTo: 'Verplaatsing naar', bookAsTransferTo: 'Verplaatsing naar',
bookAsTransferFrom: 'Verplaatsing van', bookAsTransferFrom: 'Verplaatsing van',
bookAsFeeOn: 'Kosten bij {soort} van {datum}', bookAsFeeOn: 'Kosten bij {soort} van {datum}',
@@ -2003,7 +1987,6 @@ export const nl = {
provToGaps: 'Naar dit gat onder Gaten', provToGaps: 'Naar dit gat onder Gaten',
provSpotNote: 'tot {pct} naast de slotkoers', provSpotNote: 'tot {pct} naast de slotkoers',
provCarriedNote: 'tot {n} dagen oud', provCarriedNote: 'tot {n} dagen oud',
tabSource: 'Bron',
diagCard_noCoverage: 'Geen integratie levert koersen', diagCard_noCoverage: 'Geen integratie levert koersen',
diagCard_allInactive: '{namen} staat uit', diagCard_allInactive: '{namen} staat uit',
diagCard_noChoice: 'Geen bronkeuze', diagCard_noChoice: 'Geen bronkeuze',
@@ -2156,77 +2139,91 @@ export const nl = {
}, },
portfolios: { portfolios: {
maxFree: 'De gratis versie houdt het op {max} portefeuille. Met een licentie vervalt die grens; ' maxFree: 'De gratis versie houdt het op {max} portfolio. Met een licentie vervalt die grens; '
+ 'er wordt intussen niets weggehaald.', + 'er wordt intussen niets weggehaald.',
title: 'Portefeuilles', title: "Portfolio's",
switchFailed: 'Wisselen is niet gelukt.', switchFailed: 'Wisselen is niet gelukt.',
nameRequired: 'Een portefeuille moet een naam hebben.', nameRequired: 'Een portfolio moet een naam hebben.',
nameTooLong: 'Een portefeuillenaam is hoogstens {max} tekens lang.', nameTooLong: 'Een portfolionaam is hoogstens {max} tekens lang.',
nameIsDirRef: '"{naam}" is geen naam maar een verwijzing naar een map.', nameIsDirRef: '"{naam}" is geen naam maar een verwijzing naar een map.',
nameForbiddenChars: 'Een portefeuillenaam mag geen van deze tekens bevatten: < > : " / \\ | ? *', nameForbiddenChars: 'Een portfolionaam mag geen van deze tekens bevatten: < > : " / \\ | ? *',
nameTrailing: 'Een portefeuillenaam mag niet op een spatie of een punt eindigen.', nameTrailing: 'Een portfolionaam mag niet op een spatie of een punt eindigen.',
nameLeadingSpace: 'Een portefeuillenaam mag niet met een spatie beginnen.', nameLeadingSpace: 'Een portfolionaam mag niet met een spatie beginnen.',
nameReserved: '"{naam}" is een gereserveerde naam in Windows en kan geen map worden.', nameReserved: '"{naam}" is een gereserveerde naam in Windows en kan geen map worden.',
nameTaken: '"{naam}" is een naam die de app zelf gebruikt; kies een andere.', nameTaken: '"{naam}" is een naam die de app zelf gebruikt; kies een andere.',
activeCannotRename: '"{naam}" is de portefeuille die nu open is en kan niet hernoemd worden. Wissel eerst naar een andere portefeuille; de app start daarbij opnieuw.', activeCannotRename: '"{naam}" is het portfolio dat nu open is en kan niet hernoemd worden. Wissel eerst naar een ander portfolio; de app start daarbij opnieuw.',
activeCannotArchive: '"{naam}" is de portefeuille die nu open is en kan niet weggegooid worden. Wissel eerst naar een andere portefeuille; de app start daarbij opnieuw.', activeCannotArchive: '"{naam}" is het portfolio dat nu open is en kan niet weggegooid worden. Wissel eerst naar een ander portfolio; de app start daarbij opnieuw.',
unknownPortfolio: 'Er is geen portefeuille "{naam}".', unknownPortfolio: 'Er is geen portfolio "{naam}".',
nameInUse: 'Er is al een portefeuille "{naam}"; namen die alleen in hoofdletters of in schrijfwijze verschillen tellen als dezelfde.', nameInUse: 'Er is al een portfolio "{naam}"; namen die alleen in hoofdletters of in schrijfwijze verschillen tellen als dezelfde.',
archiveFull: 'Het archief zit vol met mappen die "{naam}" heten; ruim er eerst een paar op.', archiveFull: 'Het archief zit vol met mappen die "{naam}" heten; ruim er eerst een paar op.',
notInArchive: 'Er staat geen "{naam}" in het archief.', notInArchive: 'Er staat geen "{naam}" in het archief.',
passwordRequired: 'Vul een wachtwoord in.', passwordRequired: 'Vul een wachtwoord in.',
intro: 'Elke portefeuille is een eigen administratie: een eigen database én eigen geheimen, in een eigen map. Er is er altijd precies één open.', intro: 'Elk portfolio is een eigen administratie: een eigen database én eigen geheimen, in een eigen map. Er is er altijd precies één open; de kaart daarvan staat bovenaan.',
openNow: 'Nu open', openNow: 'Nu open',
activate: 'Openen…', activate: 'Openen',
activateConfirm: 'Overschakelen naar "{naam}"? De app start daarvoor opnieuw op.', activateConfirm: 'Overschakelen naar "{naam}"? De app start daarvoor opnieuw op.',
activateWaiting: 'De app start opnieuw…', activateWaiting: 'De app start opnieuw…',
activateNoSupervisor: 'De keuze is vastgelegd, maar deze app is gestart zonder de supervisor (npm run start:server). Sluit de app en start hem opnieuw om in "{naam}" te werken.', activateNoSupervisor: 'De keuze is vastgelegd, maar deze app is gestart zonder de supervisor (npm run start:server). Sluit de app en start hem opnieuw om in "{naam}" te werken.',
create: 'Nieuwe portefeuille…', create: 'Nieuw portfolio',
createConfirm: 'Aanmaken',
createName: 'Naam', createName: 'Naam',
createNameHint: 'De naam wordt de mapnaam op schijf.', createNameHint: 'De naam wordt de mapnaam op schijf.',
createCurrency: 'Rekenvaluta', createCurrency: 'Rekenvaluta',
createDone: 'Portefeuille "{naam}" aangemaakt.', createDone: 'Portfolio "{naam}" aangemaakt.',
copy: 'Kopiëren…', copy: 'Kopiëren',
copyTitle: 'Kopie van "{naam}"', copyTitle: 'Kopie van "{naam}"',
copyName: 'Naam van de kopie', copyName: 'Naam van de kopie',
copyConfirm: 'Kopiëren', copyConfirm: 'Kopiëren',
copyDone: 'Portefeuille "{naam}" aangemaakt als kopie van "{bron}".', copyDone: 'Portfolio "{naam}" aangemaakt als kopie van "{bron}".',
createHint: 'Nieuwe portefeuille geeft een verse administratie zonder verbindingen. Kopiëren doe je per portefeuille in de lijst; een kopie neemt de database én de geheimen samen mee, want een geheim hoort bij een rij en klopt alleen bij die database.', createHint: 'Nieuw portfolio geeft een verse administratie zonder verbindingen. Kopiëren doe je via het menu (…) op de kaart; een kopie neemt de database én de geheimen samen mee, want een geheim hoort bij een rij en klopt alleen bij die database.',
createCurrencyHint: 'De rekenvaluta is de munt waarin de portefeuille rekent en alle bedragen toont. Je kiest hem bij een nieuwe portefeuille en daarna ligt hij vast; een kopie houdt de munt van het origineel.', createCurrencyHint: 'De rekenvaluta is de munt waarin het portfolio rekent en alle bedragen toont. Je kiest hem bij een nieuw portfolio en daarna ligt hij vast; een kopie houdt de munt van het origineel.',
rename: 'Hernoemen…', rename: 'Hernoemen',
renameTo: 'Nieuwe naam', renameTo: 'Nieuwe naam',
renameDone: 'Hernoemd naar "{naam}".', renameDone: 'Hernoemd naar "{naam}".',
renameActive: 'De portefeuille die nu open is, kan niet hernoemd worden: zijn database staat open. Schakel eerst over naar een andere.', renameActive: 'Het portfolio dat nu open is, kan niet hernoemd worden: zijn database staat open. Schakel eerst over naar een andere.',
remove: 'Verwijderen…', remove: 'Verwijderen',
removeConfirm: 'Portefeuille "{naam}" verwijderen? De map verhuist naar het archief; er wordt niets gewist. Typ ter bevestiging de naam over.', removeConfirm: 'Portfolio "{naam}" verwijderen? De map verhuist naar het archief; er wordt niets gewist. Typ ter bevestiging de naam over.',
removeTypeLabel: 'Typ de naam', removeTypeLabel: 'Typ de naam',
removeMismatch: 'Dat is niet de naam; er is niets gewijzigd.', removeMismatch: 'Dat is niet de naam; er is niets gewijzigd.',
removeDone: '"{naam}" staat nu in het archief.', removeDone: '"{naam}" staat nu in het archief.',
empty: 'Er is nog geen andere portefeuille.',
noDatabase: 'nog geen database', noDatabase: 'nog geen database',
lastUsed: 'laatst gebruikt',
never: 'nog niet gebruikt', never: 'nog niet gebruikt',
cardSince: '{munt}, sinds {jaar}',
factAssets: 'Assets in gebruik',
factAccounts: 'Actieve accounts',
factEvents: 'Boekingen',
factValuations: 'Balansen',
factLastUsed: 'Laatst gebruikt',
factBackup: 'Laatste back-up',
backupNever: 'nog nooit',
more: 'Meer acties',
backupTitle: 'Back-up',
backupLateHint: 'De laatste back-up kleurt oranje als hij ouder is dan {dagen} dagen of er nog nooit een is gemaakt. Terugzetten en leeg beginnen staan in het menu (…) van het portfolio dat open is en gelden alleen daarvoor.',
archiveCount: 'Archief ({n})',
demoBadge: 'Voorbeeld',
demoHint: 'Demo is een voorbeeldadministratie met bitcoin, een exchange, een eigen wallet en een bankrekening, gevuld over de afgelopen tweeënhalf jaar. Hij telt niet mee voor de grens van de gratis editie. Openen is een herstart; terug naar je eigen portfolio ook.',
archiveTitle: 'Archief', archiveTitle: 'Archief',
archiveIntro: 'Verwijderde portefeuilles staan hier. Ze tellen nergens meer mee, maar de bestanden zijn er nog.', archiveIntro: "Verwijderde portfolio's staan hier. Ze tellen nergens meer mee, maar de bestanden zijn er nog.",
archiveEmpty: 'Het archief is leeg.', purge: 'Definitief verwijderen',
purge: 'Definitief verwijderen…',
purgeConfirm: 'De map van "{naam}" en alles erin definitief verwijderen? Database, geheimen en historie zijn daarna weg. Dit kan niet ongedaan gemaakt worden. Typ ter bevestiging de naam over.', purgeConfirm: 'De map van "{naam}" en alles erin definitief verwijderen? Database, geheimen en historie zijn daarna weg. Dit kan niet ongedaan gemaakt worden. Typ ter bevestiging de naam over.',
purgeDone: '"{naam}" is definitief verwijderd.', purgeDone: '"{naam}" is definitief verwijderd.',
settingsTitle: 'Instellingen van deze portefeuille', settingsButton: 'Instellingen',
settingsOf: 'Instellingen van {naam}',
hintTitle: 'Wat zie je hier', hintTitle: 'Wat zie je hier',
authTitle: 'Toegang tot deze portefeuille', authTitle: 'Toegang tot dit portfolio',
authNone: 'Geen wachtwoord ingesteld. De app is daardoor alleen vanaf deze machine bereikbaar.', authNone: 'Geen wachtwoord ingesteld.',
authSet: 'Er is een wachtwoord ingesteld.', authSet: 'Er is een wachtwoord ingesteld.',
authEnv: 'Ingesteld via de omgevingsvariabele ASSETAPP_PASSWORD. Zolang die er staat, wint dat van wat je hier invult.', authEnv: 'Ingesteld via de omgevingsvariabele ASSETAPP_PASSWORD. Zolang die er staat, wint dat van wat je hier invult.',
authChange: 'Instellen of wijzigen…', authChange: 'Wachtwoord instellen',
authClear: 'Leegmaken…', authSave: 'Opslaan',
authClear: 'Leegmaken',
authPassword: 'Wachtwoord', authPassword: 'Wachtwoord',
authClearConfirm: 'Het wachtwoord van deze portefeuille weghalen? Daarna is de app alleen nog vanaf deze machine bereikbaar.', authClearConfirm: 'Het wachtwoord van dit portfolio weghalen? Daarna is de app alleen nog vanaf deze machine bereikbaar.',
authDone: 'Opgeslagen. Het geldt vanaf de volgende keer dat de app start.', authDone: 'Opgeslagen. Het geldt vanaf de volgende keer dat de app start.',
authClearedDone: 'Weggehaald. Het geldt vanaf de volgende keer dat de app start.', authClearedDone: 'Weggehaald. Het geldt vanaf de volgende keer dat de app start.',
authLogout: 'Uitloggen', authLogout: 'Uitloggen',
authLogoutConfirm: 'Uitloggen? Je moet het wachtwoord opnieuw invoeren om verder te kunnen.', authLogoutConfirm: 'Uitloggen? Je moet het wachtwoord opnieuw invoeren om verder te kunnen.',
authHint: 'Van een ander apparaat erbij kunnen vergt een certificaat én inloggegevens. Het certificaat hoort bij deze installatie; de inloggegevens bij deze portefeuille.', authHint: 'Zonder wachtwoord is de app alleen vanaf deze machine bereikbaar. Van een ander apparaat erbij kunnen vergt een certificaat én inloggegevens. Het certificaat hoort bij deze installatie; de inloggegevens bij dit portfolio.',
startYearTitle: 'Startjaar van deze administratie', startYearTitle: 'Startjaar van deze administratie',
startYearLabel: 'Begin overzichten bij', startYearLabel: 'Begin overzichten bij',
startYearAuto: 'Automatisch (vanaf de eerste boeking)', startYearAuto: 'Automatisch (vanaf de eerste boeking)',
@@ -2244,7 +2241,6 @@ export const nl = {
}, },
dbfile: { dbfile: {
title: 'De hele administratie',
passTooShort: 'Kies een wachtwoord van minstens 8 tekens voor de back-up.', passTooShort: 'Kies een wachtwoord van minstens 8 tekens voor de back-up.',
notABackup: 'Dit bestand is geen AssetApp-back-up (te kort).', notABackup: 'Dit bestand is geen AssetApp-back-up (te kort).',
notABackupUseRestore: 'Dit bestand is geen AssetApp-back-up. Een losse .sqlite zet je terug met "Terugzetten".', notABackupUseRestore: 'Dit bestand is geen AssetApp-back-up. Een losse .sqlite zet je terug met "Terugzetten".',
@@ -2265,20 +2261,21 @@ export const nl = {
versionNewerThanApp: 'Deze database is versie {versie} en dus nieuwer dan de app ({app}). Werk de app bij voordat je hem terugzet.', versionNewerThanApp: 'Deze database is versie {versie} en dus nieuwer dan de app ({app}). Werk de app bij voordat je hem terugzet.',
emptyFile: 'Leeg bestand ontvangen.', emptyFile: 'Leeg bestand ontvangen.',
notSqlite: 'Dit is geen SQLite-bestand (de bestandskop klopt niet).', notSqlite: 'Dit is geen SQLite-bestand (de bestandskop klopt niet).',
backup: 'Back-up maken…', backup: 'Back-up',
backupConfirm: 'Back-up maken',
backupPass: 'Wachtwoord voor de back-up', backupPass: 'Wachtwoord voor de back-up',
backupPassAgain: 'Wachtwoord herhalen', backupPassAgain: 'Wachtwoord herhalen',
backupPassMismatch: 'De twee wachtwoorden zijn niet gelijk.', backupPassMismatch: 'De twee wachtwoorden zijn niet gelijk.',
backupDone: 'Back-up gemaakt. Bewaar het wachtwoord goed: zonder wachtwoord is de back-up niet terug te zetten.', backupDone: 'Back-up gemaakt. Bewaar het wachtwoord goed: zonder wachtwoord is de back-up niet terug te zetten.',
export: 'Database exporteren', export: 'Database exporteren',
import: 'Terugzetten…', import: 'Terugzetten',
confirmRestoreBackup: 'Deze back-up terugzetten? Dat vervangt de database ÉN de geheimen (secrets.json in de map van deze portefeuille). Van allebei blijft de vorige versie als .previous staan.', confirmRestoreBackup: 'Deze back-up terugzetten? Dat vervangt de database ÉN de geheimen (secrets.json in de map van dit portfolio). Van allebei blijft de vorige versie als .previous staan.',
restoredSecrets: 'Geheimen meeteruggezet:', restoredSecrets: 'Geheimen meeteruggezet:',
confirmImport: 'De huidige database vervangen door dit bestand? De vorige blijft als .previous naast het databasebestand staan.', confirmImport: 'De huidige database vervangen door dit bestand? De vorige blijft als .previous naast het databasebestand staan.',
importDone: 'Database teruggezet', importDone: 'Database teruggezet',
importOrphans: 'Let op, verweesde verwijzingen gevonden:', importOrphans: 'Let op, verweesde verwijzingen gevonden:',
hint: 'De back-up bevat de database én de geheimen, versleuteld. De export is alleen de database: genoeg om te lezen, niet genoeg om alles terug te zetten.', hint: 'De back-up bevat de database én de geheimen, versleuteld. De export is alleen de database: genoeg om te lezen, niet genoeg om alles terug te zetten.',
fresh: 'Leeg beginnen…', fresh: 'Leeg beginnen',
confirmFresh: 'Met een lege administratie beginnen? Er komt een verse database in gebruik; de huidige blijft als .previous naast het databasebestand staan. Maak eerst een back-up als je hier ooit nog bij wilt.', confirmFresh: 'Met een lege administratie beginnen? Er komt een verse database in gebruik; de huidige blijft als .previous naast het databasebestand staan. Maak eerst een back-up als je hier ooit nog bij wilt.',
freshWord: 'leeg', freshWord: 'leeg',
freshTypeLabel: 'Typ ter bevestiging het woord', freshTypeLabel: 'Typ ter bevestiging het woord',
@@ -2301,7 +2298,7 @@ export const nl = {
nSkipped: 'overgeslagen', nSkipped: 'overgeslagen',
account: 'Account (exchange) voor deze import', account: 'Account (exchange) voor deze import',
chooseAccount: '(kies account)', chooseAccount: '(kies account)',
newAccount: '+ nieuw exchange-account…', newAccount: '+ nieuw exchange-account',
newAccountPh: 'Naam nieuw account', newAccountPh: 'Naam nieuw account',
assetsTitle: 'Assets', assetsTitle: 'Assets',
assetOk: 'gekoppeld', assetOk: 'gekoppeld',
+3 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8 // Generated, do not edit. sha256:aff17b8d5d572bccce031cf1d513314ef3a7957125c4d0ff5f5851fdb069432a
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js'; import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js'; import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
import { listConnections } from '../data/connections.js'; import { listConnections } from '../data/connections.js';
@@ -121,7 +121,8 @@ async function buildTable(el) {
if (!(await dialog.confirm({ message: t('accounts.confirmDelete'), danger: true }))) return; if (!(await dialog.confirm({ message: t('accounts.confirmDelete'), danger: true }))) return;
try { await deleteAccount(id); reload(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); } try { await deleteAccount(id); reload(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
}, },
toolbarActions: [{ id: 'merge', icon: MERGE, label: t('accounts.merge'), title: t('accounts.mergeTitle') }], toolbarActions: locked('accountMerge') ? []
: [{ id: 'merge', icon: MERGE, label: t('accounts.merge'), title: t('accounts.mergeTitle') }],
onToolbarAction: (id, anchor) => { if (id === 'merge') openMergeMenu(anchor); }, onToolbarAction: (id, anchor) => { if (id === 'merge') openMergeMenu(anchor); },
rowExpansion: () => '', rowExpansion: () => '',
onExpansionMount: (a, host) => mountExpansion(host, a.id), onExpansionMount: (a, host) => mountExpansion(host, a.id),
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:1f57286ac82ef2aadfb0cdfa809fcb1f771cf74bba815b7e579bbb106a986dd4 // Generated, do not edit. sha256:b2711d1eac7d201be0c0b5dbcc1d76495b068fd4828670afac37d02cd756c98f
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
import { listConnections, saveConnection, setActiveConnection, deleteConnection, recordConnectionStatus } from '../data/connections.js'; import { listConnections, saveConnection, setActiveConnection, deleteConnection, recordConnectionStatus } from '../data/connections.js';
import { localDateTimeNl } from '../../shared/time.js'; import { localDateTimeNl } from '../../shared/time.js';
@@ -171,9 +171,9 @@ async function marketFrameHtml() {
<span class="connp-dot${cls}" title="${esc(label + when)}"></span> <span class="connp-dot${cls}" title="${esc(label + when)}"></span>
<div class="connp-name">Atreides Services</div> <div class="connp-name">Atreides Services</div>
${url ? `<div class="connp-url" title="${esc(url)}">${esc(url)}</div>` : ''} ${url ? `<div class="connp-url" title="${esc(url)}">${esc(url)}</div>` : ''}
<div class="connp-url" title="${esc(stand)}">${esc(stand)}</div>
</div> </div>
</div> </div>
<div class="t-body-sm t-muted">${esc(stand)}</div>
${lastFailure} ${lastFailure}
<div class="conn-foot"> <div class="conn-foot">
<button class="btn btn-primary btn-md" data-market-test>${t('connections.test')}</button> <button class="btn btn-primary btn-md" data-market-test>${t('connections.test')}</button>
+18 -25
View File
@@ -1,9 +1,10 @@
// Generated, do not edit. sha256:1c9c0ff79637180d3780a7e1dd1e59dbb03533899d9a66468c9cdf709d9ee0fe // Generated, do not edit. sha256:29c2ecea7f7203cf2a789f574d5f90a158b4c2ec943bae44c680cc1e970776b5
import { t, localeTag } from '../i18n/index.js'; import { t, localeTag } from '../i18n/index.js';
import { listAssets } from '../data/assets.js'; import { listAssets } from '../data/assets.js';
import { liveSpotFor, basePriceOf } from '../price/providers.js'; import { liveSpotFor, basePriceOf } from '../price/providers.js';
import { fmtMoney, fmtInt, currencySymbol, currencyName, parseLocaleNumber } from '../ui/format.js'; import { fmtMoney, fmtInt, currencySymbol, currencyName, parseLocaleNumber } from '../ui/format.js';
import { baseCurrency } from '../app-settings.js'; import { baseCurrency } from '../app-settings.js';
import { setPageHint } from '../ui/page-hint.js';
const TO_BTC = { btc: 1, mbtc: 1e-3, bit: 1e-6, sat: 1e-8, msat: 1e-11 }; const TO_BTC = { btc: 1, mbtc: 1e-3, bit: 1e-6, sat: 1e-8, msat: 1e-11 };
const UNITS = ['fiat', 'btc', 'mbtc', 'bit', 'sat', 'msat']; const UNITS = ['fiat', 'btc', 'mbtc', 'bit', 'sat', 'msat'];
@@ -28,35 +29,27 @@ function formatUnit(unit, value) {
export async function render(el) { export async function render(el) {
const n = fmtInt; const n = fmtInt;
el.innerHTML = ` el.innerHTML = `
<div class="card conv-card">
<div class="card price-hero"> <div class="conv-head">
<div>
<div class="t-label price-label">${t('conversion.btcRate')}</div> <div class="t-label price-label">${t('conversion.btcRate')}</div>
<div class="t-value price-val" id="price-display">€ -</div> <div class="t-value price-val" id="price-display">€ -</div>
<div class="price-meta" id="price-meta">${t('conversion.loading')}</div> <div class="price-meta" id="price-meta">${t('conversion.loading')}</div>
</div> </div>
</div> <div class="conv-grid">
${field('fiat', t('conversion.fiat'), '€ EUR', t('conversion.fiatHint'), true)}
<div class="conv-grid"> ${field('btc', 'Bitcoin', '₿ BTC', t('conversion.btcHint'))}
${field('fiat', t('conversion.fiat'), '€ EUR', t('conversion.fiatHint'), true)} ${field('mbtc', 'Millibitcoin', 'mBTC', `1 BTC = ${n(1e3)} mBTC`)}
${field('btc', 'Bitcoin', '₿ BTC', t('conversion.btcHint'))} ${field('bit', 'Bit (µBTC)', 'bit', `1 BTC = ${n(1e6)} bits`)}
${field('mbtc', 'Millibitcoin', 'mBTC', `1 BTC = ${n(1e3)} mBTC`)} ${field('sat', 'Satoshi', 'sat', `1 BTC = ${n(1e8)} sats`)}
${field('bit', 'Bit (µBTC)', 'bit', `1 BTC = ${n(1e6)} bits`)} ${field('msat', 'Millisatoshi', 'msat', `1 sat = ${n(1e3)} msats · Lightning`)}
${field('sat', 'Satoshi', 'sat', `1 BTC = ${n(1e8)} sats`)}
${field('msat', 'Millisatoshi', 'msat', `1 sat = ${n(1e3)} msats · Lightning`)}
</div>
<div class="info">
<div class="t-label info-title">${t('conversion.hierarchy')}</div>
<div class="info-body">
<strong>1 BTC</strong> = ${n(1e3)} mBTC = ${n(1e6)} bits = ${n(1e8)} sats = ${n(1e11)} msats<br>
<strong>1 mBTC</strong> = ${n(1e3)} bits = ${n(1e5)} sats<br>
<strong>1 bit</strong> = 100 sats · <strong>1 sat</strong> = ${n(1e3)} msats
</div> </div>
<div class="info-note">${t('conversion.satNote')}</div> </div>`;
</div> setPageHint(el, { title: t('conversion.hierarchy'), rows: [
`1 BTC = ${n(1e3)} mBTC = ${n(1e6)} bits = ${n(1e8)} sats = ${n(1e11)} msats`,
<div class="conv-footer">${t('conversion.footer')}</div>`; `1 mBTC = ${n(1e3)} bits = ${n(1e5)} sats`,
`1 bit = 100 sats · 1 sat = ${n(1e3)} msats`,
t('conversion.satNote'),
] });
const q = id => el.querySelector('#' + id); const q = id => el.querySelector('#' + id);
const currency = baseCurrency(); const currency = baseCurrency();
+115
View File
@@ -0,0 +1,115 @@
// Generated, do not edit. sha256:8e7f997881d1fd01e18e1023544e1df65cb2a48e77ba25495de5fe5ba3785bef
import { setAppSetting } from '../app-settings.js';
import { t } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
import * as dialog from '../ui/dialog.js';
export async function backupDb() {
const res = await dialog.form({
title: t('dbfile.backup'),
fields: [{ key: 'w1', label: t('dbfile.backupPass'), type: 'password' },
{ key: 'w2', label: t('dbfile.backupPassAgain'), type: 'password' }],
confirmLabel: t('dbfile.backupConfirm'),
});
if (!res) return false;
if (res.w1 !== res.w2) { dialog.alert({ title: t('dialog.error'), message: t('dbfile.backupPassMismatch') }); return false; }
try {
const r = await fetch('/api/db/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ passphrase: res.w1 }),
});
if (!r.ok) throw new Error(serverFoutTekst(await r.json().catch(() => null)) || `HTTP ${r.status}`);
const blob = await r.blob();
const naam = (r.headers.get('Content-Disposition') || '').match(/filename="([^"]+)"/)?.[1] || 'assetapp.assetbak';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = naam; a.click();
URL.revokeObjectURL(url);
await stempelBackup();
await dialog.alert({ message: t('dbfile.backupDone') });
return true;
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
return false;
}
}
export async function exportDb() {
const a = document.createElement('a');
a.href = '/api/db/export';
a.download = '';
a.click();
await stempelBackup();
}
export function restoreDb(onChanged = () => {}) {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.assetbak,.sqlite,.db';
fileInput.onchange = async () => {
const file = fileInput.files[0];
if (!file) return;
try {
const kop = new Uint8Array(await file.slice(0, 15).arrayBuffer());
const isBackup = new TextDecoder('latin1').decode(kop) === 'ASSETAPP-BACKUP';
if (!(await dialog.confirm({
message: isBackup ? t('dbfile.confirmRestoreBackup') : t('dbfile.confirmImport'), danger: true,
}))) return;
const headers = { 'Content-Type': 'application/octet-stream' };
if (isBackup) {
const res = await dialog.form({
title: t('dbfile.import'),
fields: [{ key: 'w', label: t('dbfile.backupPass'), type: 'password' }],
confirmLabel: t('dbfile.import'),
});
if (!res) return;
headers['X-Backup-Passphrase'] = res.w;
}
const res = await fetch('/api/db/import', { method: 'POST', headers, body: await file.arrayBuffer() });
const json = await res.json().catch(() => null);
if (!res.ok) throw new Error(serverFoutTekst(json) || `HTTP ${res.status}`);
const regels = [`${t('dbfile.importDone')} (versie ${json.versie}).`];
if (json.wezen) regels.push(`${t('dbfile.importOrphans')} ${json.wezen}`);
regels.push(json.versleuteld ? `${t('dbfile.restoredSecrets')} ${json.geheimen}` : json.geheimenWaarschuwing);
dialog.alert({ message: regels.join('\n\n') });
onChanged();
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
}
};
fileInput.click();
}
export async function startFresh(onChanged = () => {}) {
if (!(await dialog.confirm({ message: t('dbfile.confirmFresh'), danger: true }))) return;
const woord = t('dbfile.freshWord');
const res = await dialog.form({
title: t('dbfile.fresh'),
fields: [{ key: 'w', label: `${t('dbfile.freshTypeLabel')} ${woord}`, type: 'text' }],
confirmLabel: t('dbfile.fresh'),
});
if (!res) return;
if ((res.w || '').trim().toLowerCase() !== woord.toLowerCase()) {
dialog.alert({ title: t('dialog.error'), message: t('dbfile.freshWordMismatch') });
return;
}
try {
const r = await fetch('/api/db/fresh', { method: 'POST' });
const json = await r.json().catch(() => null);
if (!r.ok) throw new Error(serverFoutTekst(json) || `HTTP ${r.status}`);
dialog.alert({ message: `${t('dbfile.freshDone')} (versie ${json.versie}).\n\n${json.geheimenWaarschuwing}` });
onChanged();
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
}
}
async function stempelBackup() {
try { await setAppSetting('last_backup_at', new Date().toISOString()); } catch { }
document.dispatchEvent(new CustomEvent('providers-changed'));
}
+4 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:aff33ec09f0738c89d055fc48911d73d2e952b3818493f0f70824b837ab7c4bf // Generated, do not edit. sha256:ae28972ed451b65f2e773094a56cc2c886f8928f30dfae76134014f65aa7c8e8
import { listAccounts } from '../data/accounts.js'; import { listAccounts } from '../data/accounts.js';
import { listAssets } from '../data/assets.js'; import { listAssets } from '../data/assets.js';
import { accountHoldings } from '../data/positions.js'; import { accountHoldings } from '../data/positions.js';
@@ -156,8 +156,9 @@ function mountActivity(host, events, accountIds, { onPick = null, nowText = null
return nowText && x === todayX ? `${day} · ${nowText}` : day; return nowText && x === todayX ? `${day} · ${nowText}` : day;
}; };
const marks = items.filter(it => it.dir === 0) const marks = items.filter(it => it.dir === 0)
.map(it => ({ x: it.x, variant: MARK_VARIANT[it.kind] || 'account', label: label(it.x) })); .map(it => ({ x: it.x, variant: MARK_VARIANT[it.kind] || 'account', label: label(it.x) }))
if (nowText) marks.push({ x: todayX, variant: 'now', label: label(todayX) }); .sort((a, b) => (a.variant === 'balance') - (b.variant === 'balance'));
if (nowText) marks.unshift({ x: todayX, variant: 'now', label: label(todayX) });
const peak = Math.max(1, ...beat.points.map(p => Math.abs(p[1]))); const peak = Math.max(1, ...beat.points.map(p => Math.abs(p[1])));
mountSparkline(host, beat.points, { mountSparkline(host, beat.points, {
yRange: [-peak, peak], yRange: [-peak, peak],
+55 -93
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:5dea810da6d84ed187a1154955f4194498f812771e3ddaa6b6dc7c547a305b8d // Generated, do not edit. sha256:867f87b26d0b27fe51f4a0cb77d5196bad8cd8b58a6856168c9cad25f5776d99
import { t, localeTag } from '../i18n/index.js'; import { t, localeTag } from '../i18n/index.js';
import { CONFIG } from '../../shared/config.js'; import { CONFIG } from '../../shared/config.js';
import { esc } from '../ui/escape.js'; import { esc } from '../ui/escape.js';
@@ -8,7 +8,8 @@ import { refreshAttention } from '../ui/attention.js';
import { npubFromHex, shortKey, secretFromText, publicKeyOf, nsecFromSecret, newSecret } from '../nostr/keys.js'; import { npubFromHex, shortKey, secretFromText, publicKeyOf, nsecFromSecret, newSecret } from '../nostr/keys.js';
import { keySigner, extensionSigner, nip98Header } from '../nostr/nip98.js'; import { keySigner, extensionSigner, nip98Header } from '../nostr/nip98.js';
import { qrSvg, invoiceQrText } from '../ui/qr.js'; import { qrSvg, invoiceQrText } from '../ui/qr.js';
import { CHECK, CHEVRON } from '../ui/icons.js'; import { CHECK, CHEVRON, IMAGE } from '../ui/icons.js';
import { setPageHint } from '../ui/page-hint.js';
import { splashMarkHtml } from '../ui/splash.js'; import { splashMarkHtml } from '../ui/splash.js';
import { expanders } from '../ui/expand.js'; import { expanders } from '../ui/expand.js';
import { supportInfo, supportMessages, sendSupport, supportAttachment } from '../data/support.js'; import { supportInfo, supportMessages, sendSupport, supportAttachment } from '../data/support.js';
@@ -21,24 +22,21 @@ function buyerOf(s) {
return { text: shortKey(npub ?? s.buyer, 10), title: npub ?? s.buyer ?? '' }; return { text: shortKey(npub ?? s.buyer, 10), title: npub ?? s.buyer ?? '' };
} }
function lines(s) { function standHtml(s) {
const out = []; const out = [];
const line = (text, title = '') => out.push({ text, title });
if (s.why === 'develop' || s.why === 'forced-develop') line(t('license.cardDevelop'));
line(s.level === 'full' ? t('license.cardFull') : t('license.cardFree'));
if (s.installed) { if (s.installed) {
const buyer = buyerOf(s); const buyer = buyerOf(s);
line(t('license.cardFacts', { const range = t('license.cardFacts', {
buyer: buyer.text, issued: s.issued ?? '', issued: s.issued ?? '', from: s.installed.majorFrom,
from: s.installed.majorFrom,
to: s.installed.majorTo === 'unlimited' ? t('license.unlimited') : s.installed.majorTo, to: s.installed.majorTo === 'unlimited' ? t('license.unlimited') : s.installed.majorTo,
}), buyer.title); });
if (!s.installedCovers) line(t('license.cardOutOfRange', { version: s.version })); const [before, after] = t('license.cardFor', { npub: '§' }).split('§');
out.push(`<p class="t-body-sm">${esc(before)}<span class="badge badge-accent" title="${esc(`${buyer.title}\n${range}`)}">${esc(buyer.text)}</span>${esc(after)}</p>`);
} else {
out.push(`<p class="t-body-sm">${esc(t(s.level === 'full' ? 'license.cardFull' : 'license.cardFree'))}</p>`);
} }
if (BROKEN.has(s.why)) line(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`); if (BROKEN.has(s.why)) out.push(`<p class="t-body-sm">${esc(`${t('license.cardProblem')} ${t(`license.why_${s.why}`)}.`)}</p>`);
if (s.level !== 'full') line(t('license.cardBuy')); return out.join('');
return out;
} }
async function installAndReload(text, message) { async function installAndReload(text, message) {
@@ -57,17 +55,16 @@ async function installAndReload(text, message) {
const INFO = ['Nostr', 'Why', 'SignIn', 'Zap', 'Privacy']; const INFO = ['Nostr', 'Why', 'SignIn', 'Zap', 'Privacy'];
const link = (url, html) => `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${html}</a>`; const link = (url, html) => `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${html}</a>`;
const payHtml = (expand) => `<div class="lic-head"><div class="t-label">${say('license.payTitle')}</div></div> const legendRows = () => [
<p class="t-body-sm">${say('license.payIntro')}</p>` say('license.payIntro'),
+ ['easy', 'other'].map(group => `<div class="lic-apps"><div class="t-caption">${say(`license.payGroup_${group}`)}</div>` ...['easy', 'other'].flatMap(group => [{ heading: say(`license.payGroup_${group}`) },
+ CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group) ...CONFIG.LICENSE.PAY_APPS.filter(a => a.group === group)
.map(a => `<p class="t-body-sm">${link(a.url, `<strong>${esc(a.name)}</strong>`)} ${say(`license.payApp_${a.id}`)}</p>`).join('') .map(a => `${link(a.url, `<strong>${esc(a.name)}</strong>`)} ${say(`license.payApp_${a.id}`)}`)]),
+ '</div>').join('') say('license.payNote'),
+ `<p class="t-caption">${say('license.payNote')}</p> { heading: say('license.nostrMore') },
<details class="lic-more" ${expand.attr('nostr')}><summary class="t-body-sm">${say('license.nostrMore')}</summary>` ...INFO.map(k => `<strong>${say(`license.info${k}Head`)}</strong> ${say(`license.info${k}`)}`),
+ INFO.map(k => `<p class="t-body-sm"><strong>${say(`license.info${k}Head`)}</strong> ${say(`license.info${k}`)}</p>`).join('') ...CONFIG.LICENSE.INFO_LINKS.map(l => link(l.url, say(`license.link_${l.id}`))),
+ CONFIG.LICENSE.INFO_LINKS.map(l => `<p class="t-body-sm">${link(l.url, say(`license.link_${l.id}`))}</p>`).join('') ];
+ '</details>';
let signer = null; let signer = null;
let newKey = null; let newKey = null;
@@ -91,12 +88,12 @@ async function drawSignIn(box, s) {
const who = t(signer.kind === 'extension' ? 'license.signedInExtension' : 'license.signedIn', { npub: '§' }); const who = t(signer.kind === 'extension' ? 'license.signedInExtension' : 'license.signedIn', { npub: '§' });
const [before, after] = who.split('§'); const [before, after] = who.split('§');
const other = s.installed && s.buyer && s.buyer !== signer.pubkey; const other = s.installed && s.buyer && s.buyer !== signer.pubkey;
box.innerHTML = `${head} box.innerHTML = `
<p class="t-body-sm">${esc(before)}${npubText(signer.pubkey)}${esc(after)}</p> <div class="flex-between gap-sm"><span class="t-body-sm">${esc(before)}${npubText(signer.pubkey)}${esc(after)}</span>
<button class="btn btn-ghost btn-sm" type="button" data-signout>${say('license.signOut')}</button></div>
${other ? `<p class="t-body-sm">${say('license.otherBuyer')}</p>` : ''} ${other ? `<p class="t-body-sm">${say('license.otherBuyer')}</p>` : ''}
<div data-licenses class="mt-sm"></div> <div data-licenses class="mt-sm"></div>
<p class="t-body-sm" data-signin-msg></p> <p class="t-body-sm" data-signin-msg></p>`;
<div class="flex gap-sm"><button class="btn btn-secondary btn-sm" type="button" data-signout>${say('license.signOut')}</button></div>`;
box.querySelector('[data-signout]').onclick = () => { signer = null; drawSignIn(box, s); }; box.querySelector('[data-signout]').onclick = () => { signer = null; drawSignIn(box, s); };
await drawLicenses(box.querySelector('[data-licenses]'), box.querySelector('[data-signin-msg]'), shop); await drawLicenses(box.querySelector('[data-licenses]'), box.querySelector('[data-signin-msg]'), shop);
return; return;
@@ -147,7 +144,7 @@ async function drawSignIn(box, s) {
<button class="btn btn-secondary btn-sm" type="button" data-key>${say('license.signInKey')}</button> <button class="btn btn-secondary btn-sm" type="button" data-key>${say('license.signInKey')}</button>
<button class="btn btn-ghost btn-sm" type="button" data-new>${say('license.signInNew')}</button> <button class="btn btn-ghost btn-sm" type="button" data-new>${say('license.signInNew')}</button>
</div> </div>
${hasExtension ? '' : `<p class="t-caption mt-sm">${say('license.signInExtensionNone')}</p>`} ${hasExtension ? '' : `<p class="t-caption t-muted lic-status">${say('license.signInExtensionNone')}</p>`}
<form class="mt-md" data-key-form hidden autocomplete="off"> <form class="mt-md" data-key-form hidden autocomplete="off">
<label class="form-label" for="lic-nsec">${say('license.signInKeyLabel')}</label> <label class="form-label" for="lic-nsec">${say('license.signInKeyLabel')}</label>
<input class="input t-mono mt-sm" id="lic-nsec" type="password" autocomplete="off" spellcheck="false"> <input class="input t-mono mt-sm" id="lic-nsec" type="password" autocomplete="off" spellcheck="false">
@@ -189,6 +186,8 @@ const AWAY = new Set(['marketService.unreachable', 'marketService.timeout', 'mar
async function drawBuy() { async function drawBuy() {
const box = buyBox; const box = buyBox;
if (!box) return; if (!box) return;
box.hidden = !signer && !purchase;
if (box.hidden) return;
const head = `<div class="lic-head"><div class="t-label">${say('license.buyTitle')}</div></div>`; const head = `<div class="lic-head"><div class="t-label">${say('license.buyTitle')}</div></div>`;
if (purchase) { if (purchase) {
@@ -223,7 +222,6 @@ async function drawBuy() {
: sale ? say('license.buyClosed') : esc(problem ?? ''); : sale ? say('license.buyClosed') : esc(problem ?? '');
box.innerHTML = `${head} box.innerHTML = `${head}
<p class="t-body-sm${problem ? ' t-neg' : ''}">${price}</p> <p class="t-body-sm${problem ? ' t-neg' : ''}">${price}</p>
${sale?.open && !signer ? `<p class="t-body-sm">${say('license.buySignInFirst')}</p>` : ''}
<div class="flex gap-sm"><button class="btn btn-primary btn-sm" type="button" data-buy-go${sale?.open && signer ? '' : ' disabled'}>${say('license.buyButton')}</button></div> <div class="flex gap-sm"><button class="btn btn-primary btn-sm" type="button" data-buy-go${sale?.open && signer ? '' : ' disabled'}>${say('license.buyButton')}</button></div>
<p class="t-body-sm mt-sm" data-buy-msg></p>`; <p class="t-body-sm mt-sm" data-buy-msg></p>`;
box.querySelector('[data-buy-go]').onclick = async (e) => { box.querySelector('[data-buy-go]').onclick = async (e) => {
@@ -305,10 +303,10 @@ async function drawSupport() {
<textarea class="input mt-sm" id="lic-support" rows="3" maxlength="${info.maxText}" placeholder="${esc(t('license.supportPlaceholder'))}">${esc(supportDraft)}</textarea> <textarea class="input mt-sm" id="lic-support" rows="3" maxlength="${info.maxText}" placeholder="${esc(t('license.supportPlaceholder'))}">${esc(supportDraft)}</textarea>
<div data-support-file></div> <div data-support-file></div>
<div class="flex gap-sm mt-sm"> <div class="flex gap-sm mt-sm">
<button class="btn btn-secondary btn-sm" type="button" data-support-pick>${say('license.supportAttach')}</button>
<input type="file" accept="${esc(info.attachmentTypes.join(','))}" data-support-input hidden>
<button class="btn btn-primary btn-sm" type="button" data-support-send>${say('license.supportSend')}</button> <button class="btn btn-primary btn-sm" type="button" data-support-send>${say('license.supportSend')}</button>
<span class="t-caption">${say('license.supportAttachHint', { mb })}</span> <input type="file" accept="${esc(info.attachmentTypes.join(','))}" data-support-input hidden>
<button class="btn btn-ghost btn-sm lic-attach" type="button" data-support-pick
title="${esc(`${t('license.supportAttach')}: ${t('license.supportAttachHint', { mb })}`)}" aria-label="${esc(t('license.supportAttach'))}">${IMAGE}</button>
</div> </div>
<p class="t-body-sm mt-sm" data-support-msg></p> <p class="t-body-sm mt-sm" data-support-msg></p>
${nostr}`; ${nostr}`;
@@ -474,10 +472,12 @@ function download(name, text) {
const mark = (v) => { const mark = (v) => {
if (v === true) return `<span class="lic-feat-yes" title="${esc(t('license.featIncluded'))}">${CHECK}</span>`; if (v === true) return `<span class="lic-feat-yes" title="${esc(t('license.featIncluded'))}">${CHECK}</span>`;
if (v === false) return `<span class="lic-feat-no" title="${esc(t('license.featExcluded'))}">-</span>`; if (v === false) return `<span class="lic-feat-no" title="${esc(t('license.featExcluded'))}">-</span>`;
if (v === 'partial') return `<span class="lic-feat-part" title="${esc(t('license.featPartialTitle'))}">${esc(t('license.featPartial'))}</span>`; if (v === 'partial') return `<span class="lic-feat-part" title="${esc(t('license.featPartialTitle'))}">${CHECK}</span>`;
return `<span class="lic-feat-open" title="${esc(t('license.featUndecided'))}">?</span>`; return `<span class="lic-feat-open" title="${esc(t('license.featUndecided'))}">?</span>`;
}; };
const MARKS = '.lic-feat-yes, .lic-feat-no, .lic-feat-part, .lic-feat-open';
const inFree = f => (f.has ? CONFIG.LICENSE.FREE.HAS[f.has] : f.free); const inFree = f => (f.has ? CONFIG.LICENSE.FREE.HAS[f.has] : f.free);
function groupFree(items) { function groupFree(items) {
@@ -502,23 +502,21 @@ function featuresHtml(expand) {
<ul class="lic-feat-sub">${sub}</ul> <ul class="lic-feat-sub">${sub}</ul>
</details>`; </details>`;
}).join(''); }).join('');
const open = CONFIG.LICENSE.FEATURES.some(f => inFree(f) === null);
return ` return `
<div class="lic-hero">${splashMarkHtml()}<div class="t-label">${esc(t('license.featTitle'))}</div></div> <div class="lic-hero">${splashMarkHtml()}<div class="t-label">${esc(t('license.featTitle'))}</div></div>
<div class="lic-feat"> <div class="lic-feat">
<div class="lic-feat-row lic-feat-cols"><span></span><span>${esc(t('license.featFull'))}</span><span>${esc(t('license.featFree'))}</span></div> <div class="lic-feat-row lic-feat-cols"><span></span><span>${esc(t('license.featFull'))}</span><span>${esc(t('license.featFree'))}</span></div>
${groups} ${groups}
</div> </div>`;
${open ? `<p class="t-caption">${esc(t('license.featFreeOpen'))}</p>` : ''}`;
} }
function placeShot(shot, row) { function placeShot(shot, anchor) {
const gap = 8, pad = 8; const gap = 8, pad = 8;
const r = row.getBoundingClientRect(); const r = anchor.getBoundingClientRect();
const w = shot.offsetWidth, h = shot.offsetHeight; const w = shot.offsetWidth, h = shot.offsetHeight;
let top = r.top - gap - h; let top = r.top - gap - h;
if (top < pad) top = r.bottom + gap; if (top < pad) top = r.bottom + gap;
const left = Math.min(Math.max(pad, r.left + 24), window.innerWidth - pad - w); const left = Math.min(Math.max(pad, r.left + r.width / 2 - w / 2), window.innerWidth - pad - w);
shot.style.left = `${left}px`; shot.style.left = `${left}px`;
shot.style.top = `${Math.min(Math.max(pad, top), window.innerHeight - pad - h)}px`; shot.style.top = `${Math.min(Math.max(pad, top), window.innerHeight - pad - h)}px`;
} }
@@ -531,12 +529,12 @@ function bindFeatures(root) {
document.body.appendChild(shot); document.body.appendChild(shot);
let current = null; let current = null;
let on = null; let on = null;
const show = (row) => { const show = (row, anchor = row) => {
if (row === on && !shot.hidden) return; if (anchor === on && !shot.hidden) return;
on = row; on = anchor;
root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row)); root.querySelectorAll('[data-feature]').forEach(r => r.classList.toggle('is-on', r === row));
shot.hidden = false; shot.hidden = false;
placeShot(shot, row); placeShot(shot, anchor);
const id = row.dataset.feature; const id = row.dataset.feature;
if (id === current) return; if (id === current) return;
current = id; current = id;
@@ -555,8 +553,9 @@ function bindFeatures(root) {
}; };
const over = (e) => { const over = (e) => {
if (!root.isConnected) { stop(); return; } if (!root.isConnected) { stop(); return; }
const row = e.target.closest?.('[data-feature]'); const mark = e.target.closest?.(MARKS);
if (row && root.contains(row)) show(row); else hide(); const row = mark?.closest('[data-feature]');
if (row && root.contains(row)) show(row, mark); else hide();
}; };
const stop = () => { const stop = () => {
shot.remove(); shot.remove();
@@ -574,35 +573,27 @@ function bindFeatures(root) {
export async function render(el) { export async function render(el) {
const s = licenseSnapshot() ?? await loadLicense(); const s = licenseSnapshot() ?? await loadLicense();
const expand = await expanders('license', { const expand = await expanders('license', Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])));
nostr: false, ...Object.fromEntries(CONFIG.LICENSE.FEATURE_GROUPS.map(g => [groupKey(g), false])),
});
el.innerHTML = ` el.innerHTML = `
<div class="lic-layout"> <div class="lic-layout">
<div class="lic-main">
<div class="card lic-card" data-features>${featuresHtml(expand)}</div>
</div>
<div class="lic-side"> <div class="lic-side">
<div class="card lic-card"> <div class="card lic-card">
<div class="lic-head"> <div class="lic-head">
<div class="t-label">${esc(t('license.cardTitle'))}</div> <div class="t-label">${esc(t('license.cardTitle'))}</div>
<span class="badge badge-neutral">${esc(t('license.cardVersion', { version: s.version }))}</span> <span class="badge badge-accent">${esc(t('license.cardVersion', { version: s.version }))}</span>
</div> </div>
${lines(s).map(r => `<p class="t-body-sm"${r.title ? ` title="${esc(r.title)}"` : ''}>${esc(r.text)}</p>`).join('')} ${standHtml(s)}
<div class="lic-drop" id="lic-drop" tabindex="0" role="button">
<span class="t-body-sm">${esc(t('license.dropHere'))}</span>
<span class="t-caption">${esc(t('license.dropOr'))}</span>
<input type="file" id="lic-file" accept=".json,application/json" hidden>
</div>
<span id="lic-msg" class="t-body-sm"></span>
<section class="lic-section" data-signin></section> <section class="lic-section" data-signin></section>
<section class="lic-section" data-buy></section> <section class="lic-section" data-buy hidden></section>
</div> </div>
<div class="card lic-card">${payHtml(expand)}</div>
</div>
<div class="lic-main">
<div class="card lic-card" data-features>${featuresHtml(expand)}</div>
<div class="card lic-card lic-chat-card" data-support hidden></div> <div class="card lic-card lic-chat-card" data-support hidden></div>
</div> </div>
</div>`; </div>`;
setPageHint(el, { title: t('license.payTitle'), rows: legendRows() });
expand.bind(el); expand.bind(el);
bindFeatures(el.querySelector('[data-features]')); bindFeatures(el.querySelector('[data-features]'));
buyBox = el.querySelector('[data-buy]'); buyBox = el.querySelector('[data-buy]');
@@ -610,33 +601,4 @@ export async function render(el) {
drawSignIn(el.querySelector('[data-signin]'), s); drawSignIn(el.querySelector('[data-signin]'), s);
markLicenseSeen().then(() => refreshAttention()).catch(() => { }); markLicenseSeen().then(() => refreshAttention()).catch(() => { });
const message = el.querySelector('#lic-msg');
const install = (text) => installAndReload(text, message);
const MAX = 64 * 1024;
const zone = el.querySelector('#lic-drop');
const picker = el.querySelector('#lic-file');
async function fromFile(file) {
if (!file) return;
if (file.size > MAX) {
message.className = 't-body-sm t-neg';
message.textContent = t('license.dropTooBig');
return;
}
await install(await file.text());
}
zone.onclick = () => picker.click();
zone.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } };
picker.onchange = () => fromFile(picker.files?.[0]);
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('is-over'); };
zone.ondragleave = () => zone.classList.remove('is-over');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('is-over');
fromFile(e.dataTransfer?.files?.[0]);
};
} }
+420
View File
@@ -0,0 +1,420 @@
// Generated, do not edit. sha256:86d4e38921922eb14e5463ba3c4d6855cd2e5276d5295930d9126848cf48e05c
import { t, localeTag } from '../i18n/index.js';
import { serverFoutTekst } from '../data/_rpc.js';
import { esc } from '../ui/escape.js';
import * as dialog from '../ui/dialog.js';
import { setPageHint } from '../ui/page-hint.js';
import { getSettings, updateSettings } from '../data/preferences.js';
import { firstPositionDate } from '../data/positions.js';
import { switchPortfolio, reloadWhenBack, refreshActivePortfolio } from '../portfolio.js';
import { backupDb, exportDb, restoreDb, startFresh } from './db-file.js';
import { openPopover, closePopover } from '../ui/popover.js';
import { expanders } from '../ui/expand.js';
import { PLUS, CHEVRON } from '../ui/icons.js';
import { currencyName } from '../ui/format.js';
import { CONFIG, ENUMS } from '../../shared/config.js';
import { counterText, limitFor, locked } from '../license.js';
import { counterHtml } from '../ui/lock.js';
import { baseCurrency } from '../app-settings.js';
import { openSetup } from '../onboarding/setup.js';
async function api(pad, body) {
const opties = body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: {};
const r = await fetch(pad, opties);
if (r.status === 401) {
location.replace(CONFIG.AUTH.LOGIN_PATH);
throw new Error(t('common.sessionExpired'));
}
const uit = await r.json().catch(() => null);
if (!r.ok) throw new Error(serverFoutTekst(uit) || `HTTP ${r.status}`);
return uit;
}
const datum = (iso) => (iso ? new Date(iso).toLocaleDateString(localeTag()) : null);
const DAG_MS = 24 * 60 * 60 * 1000;
const backupTeOud = (iso) => !iso || Date.now() - new Date(iso).getTime() > CONFIG.BACKUP.REMIND_AFTER_DAYS * DAG_MS;
function menuItems(p, { full, beheer }) {
if (!beheer) return [];
const kopie = p.heeftDatabase && !full ? [['kopieer', t('portfolios.copy')]] : [];
return p.actief
? [...kopie, ['exporteer', t('dbfile.export')], ['terugzetten', t('dbfile.import')], ['inrichten', t('setup.rerun')],
['leeg', t('dbfile.fresh')]]
: [...kopie, ['hernoem', t('portfolios.rename')], ['verwijder', t('portfolios.remove')]];
}
function feitHtml(label, waarde, { late = false } = {}) {
return `<div class="pf-fact${late ? ' is-late' : ''}"><dt>${label}</dt><dd>${waarde}</dd></div>`;
}
const soortenVan = (telling, enumNaam) => ENUMS[enumNaam.toUpperCase()].filter(k => telling?.[k]);
function perSoortHtml(label, telling, enumNaam, regels) {
const getal = (n) => esc(Number(n).toLocaleString(localeTag()));
const soorten = soortenVan(telling, enumNaam);
const totaal = soorten.reduce((s, k) => s + telling[k], 0);
const lijst = soorten.map(k => `<li><span>${esc(t(`enums.${enumNaam}.${k}`))}</span><span>${getal(telling[k])}</span></li>`).join('');
return `<div class="pf-fact"><dt>${label}</dt><dd>${getal(totaal)}</dd>
<ul class="pf-kinds" style="--pf-lines:${Number(regels) || 1}">${lijst}</ul></div>`;
}
function kaartHtml(p, { full, beheer, regels }) {
const k = p.kerncijfers;
const getal = (n) => esc(Number(n).toLocaleString(localeTag()));
const sub = !p.heeftDatabase ? t('portfolios.noDatabase')
: k && k.munt ? esc(k.eersteJaar ? t('portfolios.cardSince', { munt: k.munt, jaar: k.eersteJaar }) : k.munt)
: '';
const feiten = !k ? '' : `<dl class="pf-facts">
${perSoortHtml(t('portfolios.factAssets'), k.assets, 'asset_kind', regels)}
${perSoortHtml(t('portfolios.factAccounts'), k.accounts, 'account_kind', regels)}
${feitHtml(t('portfolios.factEvents'), getal(k.boekingen))}
${feitHtml(t('portfolios.factValuations'), getal(k.balansen))}
${feitHtml(t('portfolios.factLastUsed'), esc(datum(p.laatstGebruikt) || t('portfolios.never')))}
${beheer ? feitHtml(t('portfolios.factBackup'), esc(datum(k.laatsteBackup) || t('portfolios.backupNever')),
{ late: backupTeOud(k.laatsteBackup) }) : '<div class="pf-fact"></div>'}
</dl>`;
const backupKlasse = k && backupTeOud(k.laatsteBackup) ? 'btn-primary' : 'btn-secondary';
const hoofd = !p.actief
? `<button class="btn btn-secondary btn-sm" data-actie="open" data-naam="${esc(p.naam)}">${t('portfolios.activate')}</button>`
: `<button class="btn btn-secondary btn-sm" data-actie="instellingen" data-naam="${esc(p.naam)}">${t('portfolios.settingsButton')}</button>
${beheer ? `<button class="btn ${backupKlasse} btn-sm" data-actie="backup">${t('dbfile.backup')}</button>` : ''}`;
const menu = menuItems(p, { full, beheer }).length
? `<button class="btn btn-ghost btn-sm pf-more" data-menu="${esc(p.naam)}" title="${esc(t('portfolios.more'))}" aria-label="${esc(t('portfolios.more'))}">…</button>`
: '';
return `<div class="card pf-card${p.actief ? ' is-open' : ''}">
<div class="pf-head">
<div class="pf-name">${esc(p.naam)}</div>
${p.demo ? `<span class="badge">${t('portfolios.demoBadge')}</span>` : ''}
${p.actief ? `<span class="badge badge-accent pf-head-end">${t('portfolios.openNow')}</span>` : ''}
</div>
<div class="pf-sub t-body-sm t-muted">${sub}</div>
${feiten}
<div class="pf-actions">${hoofd}${menu}</div>
</div>`;
}
function archiefRijHtml(a) {
return `<div class="flex gap-sm" style="align-items:center; flex-wrap:wrap">
<div style="flex:1 1 12rem">
<div class="t-body">${esc(a.naam)}</div>
<div class="t-body-sm t-muted">${esc(datum(a.gearchiveerd) || '')}</div>
</div>
<button class="btn btn-danger btn-sm" data-actie="wis" data-naam="${esc(a.naam)}">${t('portfolios.purge')}</button>
</div>`;
}
function authHtml(auth) {
const stand = auth.bron === 'omgeving' ? t('portfolios.authEnv')
: auth.ingesteld ? t('portfolios.authSet')
: t('portfolios.authNone');
const knoppen = auth.wijzigbaar
? `<button class="btn btn-secondary btn-sm" data-actie="auth-zet">${t('portfolios.authChange')}</button>
${auth.ingesteld ? `<button class="btn btn-ghost btn-sm" data-actie="auth-leeg">${t('portfolios.authClear')}</button>` : ''}`
: '';
const uitloggen = auth.ingesteld
? `<button class="btn btn-ghost btn-sm" data-actie="auth-uit">${t('portfolios.authLogout')}</button>`
: '';
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.authTitle')}</div>
<div class="t-body-sm mt-sm">${stand}</div>
<div class="flex gap-sm mt-md" style="flex-wrap:wrap">${knoppen}${uitloggen}</div>
</div>`;
}
function startJaarHtml({ gekozen, eersteJaar, huidigJaar }) {
const AUTO = CONFIG.START_YEAR.AUTO;
let inhoud;
if (eersteJaar == null) {
inhoud = `<div class="t-body-sm mt-sm">${t('portfolios.startYearEmpty')}</div>`;
} else {
const eersteKolom = Math.min(Math.max(eersteJaar + 1, gekozen), huidigJaar);
const stand = gekozen === AUTO
? t('portfolios.startYearAutoState', { jaar: eersteKolom })
: t('portfolios.startYearState', { jaar: eersteKolom, eerste: eersteJaar });
const jaren = [];
for (let y = eersteJaar; y <= huidigJaar; y++) jaren.push(y);
if (gekozen !== AUTO && !jaren.includes(gekozen)) jaren.unshift(gekozen);
const opties = [`<option value="${AUTO}"${gekozen === AUTO ? ' selected' : ''}>${t('portfolios.startYearAuto')}</option>`]
.concat(jaren.map(y => `<option value="${y}"${y === gekozen ? ' selected' : ''}>${y}</option>`));
inhoud = `<div class="t-body-sm mt-sm">${stand}</div>
<div class="form-group mt-md" style="margin-bottom:0">
<label class="form-label" for="startjaar">${t('portfolios.startYearLabel')}</label>
<select class="input" id="startjaar" style="max-width:320px">${opties.join('')}</select>
</div>`;
}
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.startYearTitle')}</div>
${inhoud}
</div>`;
}
function rendementHtml({ gekozen }) {
const opties = ENUMS.MWR_METHOD
.map(m => `<option value="${m}"${m === gekozen ? ' selected' : ''}>${t(`portfolios.mwr_${m}`)}</option>`)
.join('');
return `<div class="pf-blok">
<div class="t-h4">${t('portfolios.mwrTitle')}</div>
<div class="form-group mt-sm" style="margin-bottom:0">
<label class="form-label" for="mwrmethode">${t('portfolios.mwrLabel')}</label>
<select class="input" id="mwrmethode" style="max-width:320px">${opties}</select>
</div>
</div>`;
}
async function instellingenHtml() {
const [auth, instellingen, eerstePositie] = await Promise.all([
api('/api/auth/status'), getSettings(), firstPositionDate(),
]);
return authHtml(auth) + startJaarHtml({
gekozen: Number(instellingen.start_year) || CONFIG.START_YEAR.AUTO,
eersteJaar: eerstePositie ? Number(eerstePositie.slice(0, 4)) : null,
huidigJaar: new Date().getFullYear(),
}) + rendementHtml({ gekozen: String(instellingen.mwr_method || 'dietz') });
}
async function openInstellingen(naam) {
const html = await instellingenHtml();
const klaar = dialog.detail({ title: t('portfolios.settingsOf', { naam }), html: `<div data-pf-settings>${html}</div>` });
const host = document.querySelector('[data-pf-settings]');
host?.closest('.dialog')?.classList.add('pf-settings-dialog');
const ververs = async () => { if (host?.isConnected) host.innerHTML = await instellingenHtml(); };
const fout = (e) => dialog.alert({ title: t('dialog.error'), message: e.message });
if (host) {
host.onclick = async (ev) => {
const knop = ev.target.closest('[data-actie]');
if (!knop) return;
try {
if (knop.dataset.actie === 'auth-zet' && await authZetten()) await ververs();
if (knop.dataset.actie === 'auth-leeg' && await authLeegmaken()) await ververs();
if (knop.dataset.actie === 'auth-uit') await uitloggen();
} catch (e) { fout(e); }
};
host.onchange = async (ev) => {
const veld = ev.target.matches('#mwrmethode') ? { mwr_method: ev.target.value }
: ev.target.matches('#startjaar') ? { start_year: Number(ev.target.value) } : null;
if (!veld) return;
try {
await updateSettings(veld);
document.dispatchEvent(new Event('settings-changed'));
await ververs();
} catch (e) { fout(e); }
};
}
await klaar;
}
export async function render(el) {
const [overzicht, expand] = await Promise.all([
api('/api/portfolios'), expanders('portfolios', { archive: false }),
]);
refreshActivePortfolio();
const beheer = !locked('portfolios');
const max = limitFor('portfolios');
const eigen = overzicht.portefeuilles.filter(p => !p.demo).length;
const full = max !== null && eigen >= max;
const aangemaakt = p => p.kerncijfers?.aangemaakt || '';
const lijst = [...overzicht.portefeuilles]
.sort((a, b) => aangemaakt(a).localeCompare(aangemaakt(b)) || a.naam.localeCompare(b.naam, localeTag()));
const regels = Math.max(1, ...lijst.map(p => Math.max(
soortenVan(p.kerncijfers?.assets, 'asset_kind').length, soortenVan(p.kerncijfers?.accounts, 'account_kind').length)));
const nieuwTegel = full
? `<div class="pf-new is-full">${counterHtml(counterText('portfolios', eigen))}</div>`
: `<div class="pf-new"><button class="btn btn-new pf-new-btn" data-actie="nieuw" title="${esc(t('portfolios.create'))}"
aria-label="${esc(t('portfolios.create'))}">${PLUS}</button></div>`;
const archief = beheer && overzicht.archief.length
? `<details class="card pf-card pf-archive" ${expand.attr('archive')}>
<summary><span class="chev-btn">${CHEVRON}</span><span>${t('portfolios.archiveTitle')}</span>
<span class="badge pf-head-end">${overzicht.archief.length}</span></summary>
<div class="pf-archive-list">${overzicht.archief.map(archiefRijHtml).join('')}</div>
</details>`
: '';
el.innerHTML = `
<div class="pf-grid${beheer ? '' : ' is-stacked'}">
${lijst.map(p => kaartHtml(p, { full, beheer, regels })).join('')}
${nieuwTegel}
</div>
${archief ? `<div class="pf-grid pf-grid-archive">${archief}</div>` : ''}`;
expand.bind(el);
const instellingenUitleg = [
{ heading: t('portfolios.authTitle') }, t('portfolios.authHint'),
{ heading: t('portfolios.startYearTitle') }, t('portfolios.startYearHint'),
{ heading: t('portfolios.mwrTitle') },
t('portfolios.mwrHint'), t('portfolios.mwrState_dietz'), t('portfolios.mwrState_irr'),
];
setPageHint(el, {
title: t('portfolios.hintTitle'),
rows: beheer ? [
{ heading: t('portfolios.title') },
t('portfolios.intro'), t('portfolios.createHint'), t('portfolios.createCurrencyHint'),
{ heading: t('portfolios.backupTitle') }, t('dbfile.hint'), t('portfolios.backupLateHint', { dagen: CONFIG.BACKUP.REMIND_AFTER_DAYS }),
...instellingenUitleg,
{ heading: t('portfolios.archiveTitle') }, t('portfolios.archiveIntro'),
] : [
{ heading: t('portfolios.title') }, t('portfolios.intro'), t('portfolios.demoHint'),
...instellingenUitleg,
],
});
const opnieuw = () => render(el);
const fout = (e) => dialog.alert({ title: t('dialog.error'), message: e.message });
async function doe(actie, naam) {
try {
switch (actie) {
case 'open': await open(naam); break;
case 'nieuw': if (await nieuw()) await opnieuw(); break;
case 'kopieer': if (await kopieer(naam)) await opnieuw(); break;
case 'instellingen': await openInstellingen(naam); break;
case 'backup': if (await backupDb()) await opnieuw(); break;
case 'exporteer': await exportDb(); await opnieuw(); break;
case 'terugzetten': restoreDb(opnieuw); break;
case 'leeg': await startFresh(opnieuw); break;
case 'inrichten': await openSetup({ rerun: true }); await opnieuw(); break;
case 'hernoem': if (await hernoem(naam)) await opnieuw(); break;
case 'verwijder': if (await verwijder(naam)) await opnieuw(); break;
case 'wis': if (await wis(naam)) await opnieuw(); break;
}
} catch (e) { fout(e); }
}
function openMenu(knop) {
const p = lijst.find(x => x.naam === knop.dataset.menu);
if (!p) return;
const { el: pop, position } = openPopover(knop, { className: 'popover new-menu' });
pop.innerHTML = menuItems(p, { full, beheer }).map(([actie, label]) =>
`<button class="btn btn-ghost btn-sm new-menu-item" data-actie="${actie}">${esc(label)}</button>`).join('');
position();
pop.onclick = (ev) => {
const item = ev.target.closest('[data-actie]');
if (!item) return;
closePopover();
doe(item.dataset.actie, p.naam);
};
}
el.onclick = (ev) => {
const menuKnop = ev.target.closest('[data-menu]');
if (menuKnop) { openMenu(menuKnop); return; }
const knop = ev.target.closest('[data-actie]');
if (knop) doe(knop.dataset.actie, knop.dataset.naam);
};
}
async function open(naam) {
if (!await dialog.confirm({ message: t('portfolios.activateConfirm', { naam }) })) return;
const uit = await switchPortfolio(naam);
if (!uit.supervised) {
await dialog.alert({ message: t('portfolios.activateNoSupervisor', { naam }) });
return;
}
dialog.notice({ message: t('portfolios.activateWaiting') });
await reloadWhenBack();
}
async function nieuw() {
const res = await dialog.form({
title: t('portfolios.create'),
fields: [
{ key: 'naam', label: t('portfolios.createName'), placeholder: t('portfolios.createNameHint') },
{
key: 'munt', label: t('portfolios.createCurrency'), type: 'select',
options: CONFIG.PORTFOLIO.BASE_CURRENCIES.map(code => ({ value: code, label: `${currencyName(code)} (${code})` })),
value: baseCurrency(),
},
],
confirmLabel: t('portfolios.createConfirm'),
});
if (!res) return false;
const uit = await api('/api/portfolios/create', { naam: res.naam, kopieVan: null, baseCurrency: res.munt });
await dialog.alert({ message: t('portfolios.createDone', { naam: uit.naam }) });
return true;
}
async function kopieer(bron) {
const res = await dialog.form({
title: t('portfolios.copyTitle', { naam: bron }),
fields: [{ key: 'naam', label: t('portfolios.copyName'), placeholder: t('portfolios.createNameHint') }],
confirmLabel: t('portfolios.copyConfirm'),
});
if (!res) return false;
const uit = await api('/api/portfolios/create', { naam: res.naam, kopieVan: bron, baseCurrency: null });
await dialog.alert({ message: t('portfolios.copyDone', { naam: uit.naam, bron }) });
return true;
}
async function hernoem(naam) {
const res = await dialog.form({
title: t('portfolios.rename'),
fields: [{ key: 'naar', label: t('portfolios.renameTo'), value: naam }],
confirmLabel: t('portfolios.rename'),
});
if (!res) return false;
const uit = await api('/api/portfolios/rename', { van: naam, naar: res.naar });
await dialog.alert({ message: t('portfolios.renameDone', { naam: uit.naar }) });
return true;
}
async function verwijder(naam) {
const res = await dialog.form({
title: t('portfolios.remove'),
fields: [{ key: 'bevestiging', label: t('portfolios.removeConfirm', { naam }) }],
confirmLabel: t('portfolios.remove'),
});
if (!res) return false;
if (res.bevestiging.trim() !== naam) {
await dialog.alert({ title: t('dialog.error'), message: t('portfolios.removeMismatch') });
return false;
}
await api('/api/portfolios/archive', { naam });
await dialog.alert({ message: t('portfolios.removeDone', { naam }) });
return true;
}
async function wis(naam) {
const res = await dialog.form({
title: t('portfolios.purge'),
fields: [{ key: 'bevestiging', label: t('portfolios.purgeConfirm', { naam }) }],
confirmLabel: t('portfolios.purge'),
});
if (!res) return false;
if (res.bevestiging.trim() !== naam) {
await dialog.alert({ title: t('dialog.error'), message: t('portfolios.removeMismatch') });
return false;
}
await api('/api/portfolios/purge', { naam });
await dialog.alert({ message: t('portfolios.purgeDone', { naam }) });
return true;
}
async function authZetten() {
const res = await dialog.form({
title: t('portfolios.authChange'),
fields: [{ key: 'password', label: t('portfolios.authPassword'), type: 'password' }],
confirmLabel: t('portfolios.authSave'),
});
if (!res) return false;
await api('/api/auth/set', { password: res.password });
await dialog.alert({ message: t('portfolios.authDone') });
return true;
}
async function uitloggen() {
if (!await dialog.confirm({ message: t('portfolios.authLogoutConfirm') })) return;
await api('/api/logout', {});
location.replace(CONFIG.AUTH.LOGIN_PATH);
}
async function authLeegmaken() {
if (!await dialog.confirm({ message: t('portfolios.authClearConfirm'), danger: true })) return false;
await api('/api/auth/clear', {});
await dialog.alert({ message: t('portfolios.authClearedDone') });
return true;
}
-790
View File
@@ -1,790 +0,0 @@
// Generated, do not edit. sha256:242a49647c9f6c4f00caf9c66c75b5120a0a8d45f2c0c051b6878a5f798ae976
import { tableViewCfg } from '../data/preferences.js';
import {
addManualPricePoint, upsertPriceHistory, listAllPriceHistory, listPriceGaps, listPriceHistory,
ignorePriceGap, listIgnoredPriceGaps, unignorePriceGap, listPriceAbsences, deletePriceAbsences,
} from '../data/price-history.js';
import { listAssets } from '../data/assets.js';
import { deleteTableRow } from '../data/table-viewer.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtBasePrice, priceFromBase, isBaseAsset } from '../ui/format.js';
import { dayNl } from '../../shared/time.js';
import { baseCurrency, appSetting } from '../app-settings.js';
import { serviceCoverage } from '../data/market.js';
import { renderTable } from '../ui/table.js';
import { mountSparkline } from '../ui/sparkline.js';
import { fetchPriceVia, primaryProvider, fillClosesFor, daysToAsk } from '../price/providers.js';
import { getProvider, listProviders } from '../data/providers.js';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { priceSourceChain } from '../data/price-source.js';
import { loadMatrixContext, matrixCardHtml, wireMatrix } from '../ui/source-matrix.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { attentionDotHtml } from '../ui/attention.js';
import { hygieneReport } from '../data/hygiene.js';
import { dailyProvenance, provenanceCounts, provenanceRuns, PROVENANCE_KINDS } from '../../shared/price-provenance.js';
import { esc } from '../ui/escape.js';
import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js';
import { REFRESH, EYE_OFF, EYE, CHEVRON_RIGHT, CLOSE } from '../ui/icons.js';
import { create as createTabs } from '../ui/tabs.js';
import { expanders } from '../ui/expand.js';
import { notesHtml, mountNotes } from '../ui/notes.js';
import * as dialog from '../ui/dialog.js';
import { ENUMS, CONFIG } from '../../shared/config.js';
import { loadCharting, readPalette } from '../ui/chart-setup.js';
import { hexToRgb } from '../ui/charting.js';
import { isMetal, priceUnitLabel } from '../../shared/units.js';
const todayIso = () => new Date().toISOString().slice(0, 10);
export const title = () => t('priceHistory.title');
let phTable = null;
function localDateParts(iso) {
const d = new Date(iso);
const p = n => String(n).padStart(2, '0');
return { date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`, time: `${p(d.getHours())}:${p(d.getMinutes())}` };
}
function fmtDateTime(ts) {
return new Intl.DateTimeFormat(localeTag(), { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000));
}
function displayDate(r) { return r.resolution === 'timestamp' ? localDateParts(r.observedAt).date : r.observedAt.slice(0, 10); }
function resLabel(res) { return res === 'timestamp' ? t('priceHistory.resTimestamp') : t('priceHistory.resDay'); }
const dash = '<span class="t-muted">-</span>';
async function fetchDayPoint(sourceKey, asset, dateKey) {
const midnight = Math.floor(Date.parse(`${dateKey}T00:00:00Z`) / 1000);
let pt = null;
try { pt = await fetchPriceVia(sourceKey, asset, 'history_point', dateKey); } catch { pt = null; }
if (!pt) {
try {
const grid = await fetchPriceVia(sourceKey, asset, 'daily_history', dateKey);
if (Array.isArray(grid)) pt = grid.find(x => new Date(x.time * 1000).toISOString().slice(0, 10) === dateKey) || null;
} catch { }
}
if (!pt && dateKey === todayIso()) {
try { const s = await fetchPriceVia(sourceKey, asset, 'spot', dateKey); if (s && Number.isFinite(s.price)) pt = { time: midnight, price: s.price }; } catch { }
}
if (!pt || !Number.isFinite(pt.price)) return null;
return { time: pt.time ?? midnight, price: pt.price };
}
async function fetchSpotPoint(sourceKey, asset) {
let s = null;
try { s = await fetchPriceVia(sourceKey, asset, 'spot', null); } catch { return null; }
if (!s || !Number.isFinite(s.price)) return null;
return { time: Math.floor(Date.now() / 1000), price: s.price };
}
export async function render(el) {
el.innerHTML = `<div id="ph-gaps"></div>`;
alleAssets = await listAssets();
await renderCards(el);
}
const CARD_DAYS = 365;
let kaarten = new Map();
let manualId = null;
let bronNamen = new Map();
async function renderCards(el) {
const host = el.querySelector('#ph-gaps');
if (!host) return;
let gaten, genegeerd, manual, bronnen, controle, afwezig;
try {
[gaten, genegeerd, manual, bronnen, controle, afwezig] = await Promise.all([listPriceGaps(), listIgnoredPriceGaps(),
getProvider('manual').catch(() => null), bronContext().catch(() => null),
hygieneReport({ kinds: CONTROLE_SOORTEN }).catch(() => null), listPriceAbsences().catch(() => [])]);
} catch { host.innerHTML = ''; return; }
const verdachtPerAsset = new Map();
for (const f of (controle && controle.findings) || []) {
for (const r of f.rows) {
const aid = r.row.assetId;
if (!verdachtPerAsset.has(aid)) verdachtPerAsset.set(aid, new Map());
const perSoort = verdachtPerAsset.get(aid);
if (!perSoort.has(f.kind)) perSoort.set(f.kind, []);
perSoort.get(f.kind).push(r);
}
}
manualId = manual ? manual.id : null;
if (bronnen) bronNamen = new Map(bronnen.providers.map(p => [p.id, p.name]));
const groepeer = rijen => {
const m = new Map();
for (const g of rijen) { if (!m.has(g.assetId)) m.set(g.assetId, []); m.get(g.assetId).push(g); }
return m;
};
const perAsset = groepeer(gaten.rows);
const genegeerdPerAsset = groepeer(genegeerd);
const afwezigPerAsset = groepeer(afwezig);
const vandaag = todayIso();
const venster = shiftIso(vandaag, -(CARD_DAYS - 1));
const nieuw = new Map();
for (const asset of [...alleAssets].sort(byKindAndLabel)) {
if (isBaseAsset(asset)) continue;
let rijen = [];
try { rijen = await listPriceHistory(asset.id); } catch { rijen = []; }
if (!rijen.length && !perAsset.has(asset.id) && !genegeerdPerAsset.has(asset.id)) continue;
const eerste = rijen.length ? rijen[rijen.length - 1].observedAt.slice(0, 10) : vandaag;
const dagen = dailyProvenance(rijen, {
assetKind: asset.kind, manualProviderId: manualId, today: vandaag,
from: eerste > venster ? eerste : venster, until: vandaag,
});
let bevindingen = [];
if (bronnen) {
try {
const historyChain = await priceSourceChain(asset.id, 'history');
bevindingen = diagnoseSources(asset, { providers: bronnen.providers, historyChain, usage: bronnen.usage,
serviceCovers: bronnen.serviceCovered.has(asset.id) });
} catch { bevindingen = []; }
}
const verdacht = [...(verdachtPerAsset.get(asset.id) || new Map())].map(([kind, rows]) => ({ kind, rows }));
nieuw.set(asset.id, {
asset, dagen, gaps: perAsset.get(asset.id) || [], ignored: genegeerdPerAsset.get(asset.id) || [],
absent: afwezigPerAsset.get(asset.id) || [], bevindingen, verdacht,
});
}
kaarten = nieuw;
const meer = gaten.total > gaten.rows.length
? `<div class="t-caption" style="margin:-0.6rem 0 1rem">${esc(t('priceHistory.gapsMore', { n: gaten.total - gaten.rows.length }))}</div>` : '';
const html = [...kaarten.values()].map(kaartHtml).join('');
host.innerHTML = `${html ? `<div class="ph-grid">${html}</div>` : ''}${meer}`;
for (const k of kaarten.values()) tekenLijn(host, k);
const grid = host.querySelector('.ph-grid');
grid?.addEventListener('click', e => {
const kaart = e.target.closest('[data-detail]');
if (kaart) openDetail(el, Number(kaart.dataset.detail));
});
grid?.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const kaart = e.target.closest('[data-detail]');
if (kaart && e.target === kaart) { e.preventDefault(); openDetail(el, Number(kaart.dataset.detail)); }
});
}
async function bronContext() {
const month = new Date().toISOString().slice(0, 7);
const providers = (await Promise.all((await listProviders()).map(p => getProvider(p.key)))).filter(Boolean);
const usage = {};
for (const p of providers) {
if (!p.callsPerMonth) continue;
try { usage[p.id] = (await apiMetricsTotals(p.id, `${month}-01T00:00`)).calls; } catch { }
}
return { providers, usage, serviceCovered: await serviceCovered() };
}
async function serviceCovered() {
let mode = 'off';
try { mode = appSetting('market_spot'); } catch { }
if (mode === 'off') return new Set();
try {
const r = await serviceCoverage();
return new Set(r.status === 'ok' ? r.assetIds : []);
} catch { return new Set(); }
}
const CONTROLE_SOORTEN = ['price_unit_swap', 'price_outlier', 'price_sources_disagree',
'price_not_positive', 'price_in_future'];
function meldingenVan({ gaps, bevindingen = [], verdacht = [] }) {
const uit = bevindingen.map(b => ({ severity: b.severity, tekst: t(`priceHistory.diagCard_${b.code}`, { namen: (b.names || []).join(', ') }) }));
const n = verdacht.reduce((s, v) => s + v.rows.length, 0);
if (n) uit.push({ severity: 'attention', tekst: telTekst('suspect', n) });
const dagen = gaps.reduce((s, g) => s + g.days, 0);
if (gaps.length) uit.push({ severity: 'attention', tekst: t('priceHistory.cardGaps', { gaten: telTekst('gapCount', gaps.length), dagen: telTekst('dayCount', dagen) }) });
return uit;
}
const meldingHtml = m => `<span class="ph-notice">${attentionDotHtml([{ severity: m.severity, label: m.tekst, fade: 'full' }])}<span>${esc(m.tekst)}</span></span>`;
const daysIncl = (from, until) => Math.round((Date.parse(`${until}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000) + 1;
const shiftIso = (day, n) => new Date(Date.parse(`${day}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
const kindLabel = kind => t(`priceHistory.kind_${kind}`);
const telTekst = (stam, n) => t(`priceHistory.${stam}${n === 1 ? 'One' : 'Many'}`, { n });
const soortTelling = (kind, n) => t('priceHistory.kindCount', { n, soort: t(`priceHistory.kindCount_${kind}${n === 1 ? 'One' : 'Many'}`) });
function kaartHtml(k) {
const { asset, dagen } = k;
const counts = provenanceCounts(dagen);
const telling = dagen.length
? PROVENANCE_KINDS.filter(k => counts[k] > 0)
.map(k => `<span class="ph-count"><span class="ph-dot ph-k-${k}"></span>${esc(soortTelling(k, counts[k]))}</span>`)
.join('')
: `<span class="t-muted">${esc(t('priceHistory.cardNoPrices'))}</span>`;
const periode = dagen.length ? `<span class="t-muted">${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))}</span>` : '';
const melding = meldingenVan(k).map(meldingHtml).join('');
return `<div class="card wealth-card ph-card" data-asset="${asset.id}" data-detail="${asset.id}" role="button" tabindex="0"
aria-label="${esc(t('wealth.openDetail'))}: ${esc(asset.name)}">
<div class="wealth-head">
<strong>${assetLabelHtml(asset)}</strong>
<span class="badge">${esc(t('enums.asset_kind.' + asset.kind))}</span>
</div>
<div class="ph-counts t-caption">${periode}${telling}</div>
<div class="ph-notices t-caption">${melding}</div>
<div class="ph-spark-wrap">
<div class="wealth-spark ph-spark" data-ph-spark="${asset.id}"></div>
<div class="ph-strip" data-ph-strip="${asset.id}"></div>
</div>
</div>`;
}
function tekenLijn(host, { asset, dagen }) {
const lijnEl = host.querySelector(`[data-ph-spark="${asset.id}"]`);
const strookEl = host.querySelector(`[data-ph-strip="${asset.id}"]`);
if (!lijnEl || !strookEl || !dagen.length) return;
const perMs = new Map();
const punten = [];
for (const d of dagen) {
if (d.fiatPrice == null) continue;
const ms = Date.parse(`${d.day}T00:00:00Z`);
perMs.set(ms, d);
punten.push([ms, priceFromBase(d.fiatPrice, asset)]);
}
mountSparkline(lijnEl, punten, {
tone: 'accent',
formatValue: (v, ms) => {
const d = perMs.get(ms);
return [fmtBasePrice(v),d ? d.day : null, d ? kindLabel(d.kind) : null].filter(Boolean).join(' · ');
},
});
tekenStrook(strookEl, dagen);
}
function tekenStrook(strookEl, dagen) {
const n = dagen.length;
if (!n) { strookEl.innerHTML = ''; return; }
let x = 0;
const rects = provenanceRuns(dagen).map(r => {
const titel = r.days === 1
? t('priceHistory.stripDay', { dag: r.from, soort: kindLabel(r.kind) })
: t('priceHistory.stripRange', { van: r.from, tot: r.until, soort: kindLabel(r.kind) });
const rect = `<rect x="${x}" y="0" width="${r.days}" height="1" class="ph-k-${r.kind}"><title>${esc(titel)}</title></rect>`;
x += r.days;
return rect;
}).join('');
strookEl.innerHTML = `<svg viewBox="0 0 ${n} 1" preserveAspectRatio="none" aria-hidden="true">${rects}</svg>`;
}
let detailMelding = null;
let detail = null;
let detailChart = null;
async function laadDetail(asset) {
let rijen = [];
try { rijen = await listPriceHistory(asset.id); } catch { rijen = []; }
detail = { assetId: asset.id, dagen: dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manualId, today: todayIso() }) };
}
async function openDetail(el, assetId) {
const k = kaarten.get(assetId);
if (!k) return;
await laadDetail(k.asset);
dialog.detail({ title: assetLabel(k.asset), titleHtml: assetLabelHtml(k.asset),
asideHtml: `<span class="badge">${t('enums.asset_kind.' + k.asset.kind)}</span>`,
html: `<div class="wealth-detail ph-detail">
<div class="wealth-detail-tabs" data-detail-tabs></div>
<div class="wealth-detail-spark ph-detail-chart">
<div class="ph-detail-plot"><canvas data-ph-chart></canvas></div>
<div class="ph-strip" data-ph-detail-strip></div>
</div>
</div>` });
mountDetailTabs(el, assetId, 'provenance');
tekenDetailGrafiek(k.asset).catch(() => {});
}
function mountDetailTabs(el, assetId, active) {
const host = document.querySelector('.dialog-overlay [data-detail-tabs]');
const k = kaarten.get(assetId);
if (!host || !k) return;
createTabs(host, {
active,
items: [
{ id: 'provenance', label: t('priceHistory.tabProvenance') },
{ id: 'source', label: t('priceHistory.tabSource') },
{ id: 'check', label: t('priceHistory.tabCheck'), when: () => k.verdacht.length > 0 },
{ id: 'gaps', label: t('priceHistory.tabGaps'), when: () => k.gaps.length > 0 || k.ignored.length > 0 || k.absent.length > 0 },
{ id: 'prices', label: t('priceHistory.tabPrices') },
{ id: 'notes', label: t('notes.tab') },
],
render: (id, panel) => {
const m = detailMelding;
detailMelding = null;
const kop = m ? `<div class="ph-outcome t-body-sm">${esc(m)}</div>` : '';
if (id !== 'prices') { koersFilter = null; koersFocus = null; }
if (id === 'source') bronPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `<div class="t-body-sm t-neg">${esc(e.message)}</div>`; });
else if (id === 'check') { panel.innerHTML = kop + controleHtml(k); wireControle(el, panel, k); }
else if (id === 'gaps') gatenPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `<div class="t-body-sm t-neg">${esc(e.message)}</div>`; });
else if (id === 'prices') koersenPaneel(el, panel, k, kop);
else if (id === 'notes') { panel.innerHTML = kop + notesHtml(); mountNotes(panel, k.asset.id); }
else {
panel.innerHTML = kop + herkomstHtml(k, detail && detail.assetId === assetId ? detail.dagen : k.dagen);
panel.querySelectorAll('[data-to-gap]').forEach(b => b.onclick = () => {
const [from, until] = b.dataset.toGap.split('|');
gatFocus = { from, until };
mountDetailTabs(el, assetId, 'gaps');
});
}
},
});
}
async function naWijziging(el, assetId, tab, melding = null) {
document.dispatchEvent(new CustomEvent('price-gaps-changed'));
await renderCards(el);
detailMelding = melding;
const k = kaarten.get(assetId);
if (k) await laadDetail(k.asset);
mountDetailTabs(el, assetId, tab);
if (k) tekenDetailGrafiek(k.asset).catch(() => {});
}
async function tekenDetailGrafiek(asset) {
const canvas = document.querySelector('.dialog-overlay [data-ph-chart]');
const strook = document.querySelector('.dialog-overlay [data-ph-detail-strip]');
if (!canvas || !detail || detail.assetId !== asset.id) return;
if (strook) tekenStrook(strook, detail.dagen);
const Chart = await loadCharting();
const pal = readPalette();
const kleur = { close: pal.accent, manual: pal.blue, live: pal.accent, spot: pal.sigYellow, carried: pal.textSec, none: pal.sigRed };
const punten = detail.dagen.map(d => ({ x: Date.parse(`${d.day}T00:00:00Z`), y: d.fiatPrice == null ? null : priceFromBase(d.fiatPrice, asset), d }));
const soortOp = i => punten[i] && punten[i].d.kind;
const rgb = hexToRgb(pal.accent) || { r: 128, g: 128, b: 128 };
const schaduw = ({ chart }) => {
const a = chart.chartArea;
if (!a) return 'transparent';
const g = chart.ctx.createLinearGradient(0, a.top, 0, a.top + a.height * 0.85);
g.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.28)`);
g.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
return g;
};
if (detailChart) detailChart.destroy();
detailChart = new Chart(canvas.getContext('2d'), {
type: 'scatter',
data: { datasets: [{
data: punten, showLine: true, spanGaps: false, borderWidth: 1.5, cubicInterpolationMode: 'monotone',
fill: 'start', backgroundColor: schaduw,
segment: {
borderColor: c => kleur[soortOp(c.p1DataIndex)] || pal.accent,
borderDash: c => (soortOp(c.p1DataIndex) === 'carried' ? [4, 3] : undefined),
},
pointRadius: c => (['spot', 'manual'].includes(soortOp(c.dataIndex)) ? 2.5 : 0),
pointHoverRadius: 4, pointBorderWidth: 0,
pointBackgroundColor: c => kleur[soortOp(c.dataIndex)] || pal.accent,
}] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
layout: { padding: { top: 6, bottom: 3 } },
interaction: { mode: 'nearest', axis: 'x', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
filter: i => i.parsed.y != null,
backgroundColor: pal.tooltipBg, borderColor: pal.border, borderWidth: 1, titleColor: pal.accent, bodyColor: pal.text,
callbacks: {
title: items => (items[0] ? punten[items[0].dataIndex].d.day : ''),
label: i => tooltipRegels(punten[i.dataIndex].d, i.parsed.y),
},
},
zoom: { zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }, pan: { enabled: true, mode: 'x' } },
},
scales: {
x: { type: 'linear', display: false },
y: { display: false },
},
},
});
}
function tooltipRegels(d, prijs) {
const regels = [fmtBasePrice(prijs)];
const bron = d.row ? bronNamen.get(d.row.providerId) : null;
regels.push(bron ? `${kindLabel(d.kind)} · ${bron}` : kindLabel(d.kind));
if (d.kind === 'spot' && d.row) {
const moment = d.row.resolution === 'timestamp' ? d.row.observedAt : (d.row.updatedAt || d.row.createdAt);
if (moment) regels.push(t('priceHistory.tipMoment', { tijd: localDateParts(moment).time }));
const pct = v => (v == null ? '?' : `${v > 0 ? '+' : ''}${v.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%`);
if (d.deviation) regels.push(t('priceHistory.tipDeviation', { voor: pct(d.deviation.before), na: pct(d.deviation.after) }));
}
if (d.kind === 'carried') regels.push(t('priceHistory.provCarriedNote', { n: d.ageDays }));
return regels;
}
const overlapt = (r, g) => r.from <= (g.until ?? g.untilDay) && r.until >= (g.from ?? g.fromDay);
function herkomstHtml(k, dagen) {
const { asset } = k;
const counts = provenanceCounts(dagen);
const telling = PROVENANCE_KINDS.filter(k => counts[k] > 0)
.map(k => `<span class="ph-count"><span class="ph-dot ph-k-${k}"></span>${esc(soortTelling(k, counts[k]))}</span>`)
.join('');
const kop = dagen.length
? `<div class="ph-counts t-caption"><span class="t-muted">${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))}</span>${telling}</div>`
: `<p class="t-muted">${esc(t('priceHistory.cardNoPrices'))}</p>`;
const dicht = CONFIG.PRICE_HISTORY.CLOSED_MARKET_DAYS[asset.kind] ?? 0;
const zwak = provenanceRuns(dagen)
.filter(r => !['close', 'live', 'manual'].includes(r.kind) && !(r.kind === 'carried' && r.days <= dicht))
.reverse();
if (!dagen.length) return kop;
if (!zwak.length) return `${kop}<p class="t-muted">${esc(t('priceHistory.provNoWeak'))}</p>`;
const toelichting = r => {
const stuk = dagen.filter(d => d.day >= r.from && d.day <= r.until);
if (r.kind === 'carried') return t('priceHistory.provCarriedNote', { n: Math.max(...stuk.map(d => d.ageDays)) });
if (r.kind !== 'spot') return '';
const pcts = stuk.flatMap(d => [d.deviation?.before, d.deviation?.after]).filter(v => v != null);
if (!pcts.length) return '';
const max = pcts.reduce((a, b) => (Math.abs(b) > Math.abs(a) ? b : a));
return t('priceHistory.provSpotNote', { pct: `${max > 0 ? '+' : ''}${max.toLocaleString(localeTag(), { maximumFractionDigits: 2 })}%` });
};
const bronVan = r => {
const namen = [...new Set(dagen.filter(d => d.day >= r.from && d.day <= r.until && d.row)
.map(d => bronNamen.get(d.row.providerId)).filter(Boolean))];
return namen.length ? esc(namen.join(', ')) : dash;
};
const naarGat = r => (r.kind === 'none' && [...k.gaps, ...k.ignored].some(g => overlapt(r, g))
? `<button type="button" class="btn btn-ghost btn-sm gap-fill" data-to-gap="${esc(r.from)}|${esc(r.until)}"
title="${esc(t('priceHistory.provToGaps'))}" aria-label="${esc(t('priceHistory.provToGaps'))}">${CHEVRON_RIGHT}</button>` : '');
const rijen = zwak.map(r => `<tr class="ph-weak-row">
<td class="text">${esc(r.days === 1 ? r.from : t('priceHistory.provRange', { van: r.from, tot: r.until }))}</td>
<td class="text"><span class="ph-count"><span class="ph-dot ph-k-${r.kind}"></span>${esc(kindLabel(r.kind))}</span></td>
<td class="num">${r.days}</td>
<td class="text">${bronVan(r)}</td>
<td class="text t-muted">${esc(toelichting(r))}</td>
<td class="actions-cell">${naarGat(r)}</td>
</tr>`).join('');
return `<div class="ph-prov">${kop}
<div class="table-wrap"><table>
<thead><tr>
<th class="text">${esc(t('priceHistory.provColPeriod'))}</th>
<th class="text">${esc(t('priceHistory.provColKind'))}</th>
<th class="num">${esc(t('priceHistory.provColDays'))}</th>
<th class="text">${esc(t('priceHistory.provColSource'))}</th>
<th class="text">${esc(t('priceHistory.provColNote'))}</th>
<th class="actions-cell"></th>
</tr></thead>
<tbody>${rijen}</tbody>
</table></div></div>`;
}
async function bronPaneel(el, panel, k, kop) {
const { asset } = k;
const regels = k.bevindingen.map(b => {
const tekst = t(`priceHistory.diag_${b.code}${b.code === 'limitReached' && b.severity !== 'problem' ? 'Fallback' : ''}`,
{ namen: (b.names || []).join(', '), symbool: asset.symbol });
const voorstel = b.proposal
? `<div class="ph-proposal t-body-sm">${esc(t('priceHistory.diagProposal', { naam: b.proposal.providerName }))}</div>` : '';
return `<div class="ph-finding">${meldingHtml({ severity: b.severity, tekst })}${voorstel}</div>`;
}).join('');
panel.innerHTML = `${kop}${regels}<div class="mx-grid" data-ph-matrix></div>`;
const host = panel.querySelector('[data-ph-matrix]');
host.innerHTML = matrixCardHtml(asset, await loadMatrixContext(), { title: false });
wireMatrix(host, () => naWijziging(el, asset.id, 'source'));
}
function controleHtml({ verdacht }) {
const maat = c => (c.factor != null ? t('priceHistory.checkFactor', { n: c.factor })
: c.afwijkingProcent != null ? t('priceHistory.checkShare', { n: c.afwijkingProcent })
: c.verschilProcent != null ? t('priceHistory.checkShare', { n: c.verschilProcent }) : '');
const bron = r => (r.provider_name || [r.low_provider_name, r.high_provider_name].filter(Boolean).join(' / '));
return verdacht.map(({ kind, rows }, ki) => `
<div class="ph-finding">
<div class="ph-gaps-head">
<strong class="t-body-sm">${esc(t(`health.kind.${kind}`))}</strong>
<button type="button" class="btn btn-secondary btn-sm" data-check-kind="${ki}">${esc(t('priceHistory.checkShowKind'))}</button>
</div>
<p class="t-caption t-muted" style="margin:0.2rem 0 0.4rem">${esc(t(`health.why.${kind}`))}</p>
<div class="gap-list">${rows.map(({ row, counts }, ri) => `
<div class="gap-row">
<span class="t-body-sm">${esc(dayNl(localDateParts(row.observedAt).date))} · ${esc(bron(row))}</span>
<span class="row-actions">
<span class="t-caption t-muted">${esc(maat(counts || {}))}</span>
<button type="button" class="btn btn-ghost btn-sm gap-fill" data-check-row="${ki}:${ri}"
title="${esc(t('priceHistory.checkOpenRow'))}" aria-label="${esc(t('priceHistory.checkOpenRow'))}">${CHEVRON_RIGHT}</button>
</span>
</div>`).join('')}</div>
</div>`).join('') + `<p class="t-caption t-muted">${esc(t('priceHistory.checkHint'))}</p>`;
}
let koersFilter = null;
let koersFocus = null;
const dagVan = row => row.observedAt.slice(0, 10);
function filterVoor(rows) {
const ids = new Set(rows.filter(r => r.row.priceHistoryId != null).map(r => r.row.priceHistoryId));
const dagen = new Set(rows.filter(r => r.row.priceHistoryId == null).map(r => dagVan(r.row)));
return r => ids.has(r.id) || (r.resolution === 'day' && dagen.has(dagVan(r)));
}
function wireControle(el, panel, k) {
const naarKoersen = () => mountDetailTabs(el, k.asset.id, 'prices');
panel.querySelectorAll('[data-check-kind]').forEach(b => b.onclick = () => {
const { kind, rows } = k.verdacht[Number(b.dataset.checkKind)];
koersFilter = { label: t('priceHistory.checkFiltered', { soort: t(`health.kind.${kind}`), n: rows.length }), match: filterVoor(rows) };
naarKoersen();
});
panel.querySelectorAll('[data-check-row]').forEach(b => b.onclick = () => {
const [ki, ri] = b.dataset.checkRow.split(':').map(Number);
const r = k.verdacht[ki].rows[ri];
if (r.row.priceHistoryId != null) koersFocus = r.row.priceHistoryId;
else koersFilter = { label: t('priceHistory.checkFilteredDay', { dag: dagVan(r.row) }), match: filterVoor([r]) };
naarKoersen();
});
}
function koersenPaneel(el, panel, k, kop) {
const filter = koersFilter
? `<div class="ph-gaps-head ph-filter t-body-sm"><span>${esc(koersFilter.label)}</span>
<button type="button" class="btn btn-ghost btn-sm" data-clear-filter>${esc(t('priceHistory.checkClearFilter'))}</button></div>`
: '';
panel.innerHTML = kop + filter + '<div class="ph-prices" data-ph-table></div>';
panel.querySelector('[data-clear-filter]')?.addEventListener('click', () => {
koersFilter = null;
mountDetailTabs(el, k.asset.id, 'prices');
});
buildTable(el, panel.querySelector('[data-ph-table]'), k.asset);
}
function voortgang(panel) {
const box = panel.querySelector('[data-confirm]');
box.hidden = false;
box.innerHTML = `<div class="t-body-sm">${esc(t('priceHistory.gapBusy'))}</div>
<div class="ph-confirm-actions">
<button type="button" class="btn btn-secondary btn-sm" data-stop>${esc(t('priceHistory.gapStop'))}</button>
</div>`;
return box.querySelector('[data-stop]');
}
const gatKnop = (attr, i, label, icoon) =>
`<button type="button" class="btn btn-ghost btn-sm gap-fill" ${attr}="${i}" title="${esc(label)}" aria-label="${esc(label)}">${icoon}</button>`;
let gatFocus = null;
async function gatenPaneel(el, panel, k, kop) {
const dagen = k.gaps.reduce((s, g) => s + g.days, 0);
const focus = gatFocus;
gatFocus = null;
const inFocus = g => (focus && overlapt(focus, g) ? ' is-focus' : '');
const expand = await expanders('priceHistory', { ignored: false, absent: false });
const kopregel = k.gaps.length ? `
<div class="ph-gaps-head">
<span class="t-caption">${esc(t('priceHistory.gapCardSummary', { gaten: telTekst('gapCount', k.gaps.length), dagen: telTekst('dayCount', dagen) }))}</span>
<span class="row-actions">
<button type="button" class="btn btn-ghost btn-sm" data-gap-ignore-all>${esc(t('priceHistory.gapIgnoreAll'))}</button>
<button type="button" class="btn btn-secondary btn-sm" data-gap-all>${esc(t('priceHistory.gapFillAll'))}</button>
</span>
</div>` : `<p class="t-caption t-muted">${esc(t('priceHistory.gapNone'))}</p>`;
const lijst = `<div class="gap-list">${k.gaps.map((g, i) => `
<div class="gap-row${inFocus(g)}">
<span class="t-body-sm">${esc(t('priceHistory.gapLine', { van: g.from, tot: g.until, n: g.days }))}</span>
<span class="row-actions">${gatKnop('data-gap', i, t('priceHistory.gapFill'), REFRESH)}${gatKnop('data-gap-ignore', i, t('priceHistory.gapIgnore'), EYE_OFF)}</span>
</div>`).join('')}</div>`;
const openVoorFocus = k.ignored.some(g => inFocus(g)) && !expand.open('ignored');
const genegeerd = k.ignored.length ? `
<details class="ph-ignored" ${expand.attr('ignored')}${openVoorFocus ? ' open' : ''}>
<summary class="t-body-sm">${CHEVRON_RIGHT}<span>${esc(t('priceHistory.tabIgnored'))} (${k.ignored.length})</span></summary>
<div class="gap-list">${k.ignored.map(g => `
<div class="gap-row${inFocus(g)}">
<span class="t-body-sm">${esc(t('priceHistory.gapLine', { van: g.fromDay, tot: g.untilDay, n: daysIncl(g.fromDay, g.untilDay) }))}</span>
<span class="row-actions">${gatKnop('data-unignore', g.id, t('priceHistory.gapUnignore'), EYE)}</span>
</div>`).join('')}</div>
</details>` : '';
const afwezig = k.absent.length ? `
<details class="ph-ignored" ${expand.attr('absent')}>
<summary class="t-body-sm">${CHEVRON_RIGHT}<span>${esc(t('priceHistory.tabAbsent'))} (${k.absent.length})</span></summary>
<p class="t-caption t-muted">${esc(t('priceHistory.absentExplain'))}</p>
<div class="gap-list">${k.absent.map((g, i) => `
<div class="gap-row">
<span class="t-body-sm">${esc(g.days === 1 ? t('priceHistory.absentLineOne', { bron: g.providerName, dag: g.fromDay })
: t('priceHistory.absentLine', { bron: g.providerName, van: g.fromDay, tot: g.untilDay, dagen: telTekst('dayCount', g.days) }))}</span>
<span class="row-actions">${gatKnop('data-absent-forget', i, t('priceHistory.absentForget'), CLOSE)}</span>
</div>`).join('')}</div>
</details>` : '';
panel.innerHTML = `${kop}<div class="ph-confirm" data-confirm hidden></div>${kopregel}${lijst}${genegeerd}${afwezig}`;
panel.querySelector('.gap-row.is-focus')?.scrollIntoView({ block: 'center' });
expand.bind(panel);
panel.querySelector('[data-gap-all]')?.addEventListener('click', () => fillGaps(el, panel, k, k.gaps));
panel.querySelectorAll('[data-gap]').forEach(b => b.onclick = () => fillGaps(el, panel, k, [k.gaps[Number(b.dataset.gap)]]));
panel.querySelectorAll('[data-gap-ignore]').forEach(b => b.onclick = () => negeer(el, k, [k.gaps[Number(b.dataset.gapIgnore)]]));
panel.querySelector('[data-gap-ignore-all]')?.addEventListener('click', () => negeer(el, k, k.gaps));
panel.querySelectorAll('[data-unignore]').forEach(b => b.onclick = async () => {
let melding = null;
try { await unignorePriceGap(Number(b.dataset.unignore)); } catch (e) { melding = e.message; }
await naWijziging(el, k.asset.id, 'gaps', melding);
});
panel.querySelectorAll('[data-absent-forget]').forEach(b => b.onclick = async () => {
const g = k.absent[Number(b.dataset.absentForget)];
let melding = null;
try { await deletePriceAbsences({ assetId: g.assetId, providerId: g.providerId, from: g.fromDay, until: g.untilDay }); }
catch (e) { melding = e.message; }
await naWijziging(el, k.asset.id, 'gaps', melding);
});
}
async function negeer(el, k, gaps) {
const vraag = gaps.length === 1 ? t('priceHistory.gapIgnoreConfirm') : t('priceHistory.gapIgnoreAllConfirm', { n: gaps.length });
const label = t(gaps.length === 1 ? 'priceHistory.gapIgnore' : 'priceHistory.gapIgnoreAll');
if (!(await dialog.confirmOver({ title: label, message: vraag, confirmLabel: label }))) return;
let melding = null;
try {
for (const g of gaps) await ignorePriceGap({ assetId: k.asset.id, from: g.from, until: g.until });
} catch (e) { melding = e.message; }
await naWijziging(el, k.asset.id, 'gaps', melding);
}
async function fillGaps(el, panel, k, gaps) {
const { asset } = k;
if (!gaps || !gaps.length) return;
const reeks = await primaryProvider(asset, 'daily_history');
const perDatum = await primaryProvider(asset, 'history_point');
if (!reeks && !perDatum) {
detailMelding = t('priceHistory.gapNoSource', { asset: asset.symbol });
mountDetailTabs(el, asset.id, 'gaps');
return;
}
const dagen = gaps.reduce((s, g) => s + daysToAsk(asset, g.from, g.until), 0);
const vraag = reeks && perDatum ? t('priceHistory.gapConfirmBoth', { reeks: reeks.name, bron: perDatum.name, n: dagen })
: reeks ? t('priceHistory.gapConfirmSeries', { reeks: reeks.name })
: t('priceHistory.gapConfirmPoint', { bron: perDatum.name, n: dagen });
if (!(await dialog.confirmOver({ title: t('priceHistory.gapFill'), message: vraag, confirmLabel: t('priceHistory.gapFill') }))) return;
panel.querySelectorAll('button').forEach(b => { b.disabled = true; });
const btn = voortgang(panel);
const stop = new AbortController();
btn.disabled = false;
btn.onclick = () => { stop.abort(); btn.disabled = true; };
let stopped = false, limitReached = false, fout = null;
try {
for (let i = 0; i < gaps.length && !stop.signal.aborted; i++) {
btn.textContent = gaps.length > 1 ? t('priceHistory.gapStopProgress', { i: i + 1, n: gaps.length }) : t('priceHistory.gapStop');
const uit = await fillClosesFor(asset, gaps[i].from, gaps[i].until, { signal: stop.signal });
if (uit.stopped) stopped = true;
if (uit.limitReached) { limitReached = true; break; }
}
if (stop.signal.aborted) stopped = true;
} catch (e) {
fout = e.message;
}
let resterend = [];
try {
resterend = (await listPriceGaps()).rows
.filter(g => g.assetId === asset.id && gaps.some(h => g.from <= h.until && g.until >= h.from));
} catch { }
const rest = resterend.reduce((s, g) => s + g.days, 0);
let waarom = '';
if (rest && stopped) waarom = t('priceHistory.gapStopped');
else if (rest && limitReached) waarom = t('priceHistory.gapLimit');
else if (rest) {
if (perDatum) waarom = t('priceHistory.gapWhyPoint', { bron: perDatum.name });
else {
let begin = null;
try {
for (const r of await listPriceHistory(asset.id)) {
const dag = r.observedAt.slice(0, 10);
if (r.providerId === reeks.id && r.resolution === 'day' && (!begin || dag < begin)) begin = dag;
}
} catch { }
if (!begin) waarom = t('priceHistory.gapWhySeriesEmpty', { reeks: reeks.name });
else if (resterend.some(g => g.from < begin)) waarom = t('priceHistory.gapWhySeries', { reeks: reeks.name, begin });
else waarom = t('priceHistory.gapWhySeriesHole', { reeks: reeks.name, n: rest });
}
}
const melding = fout || (!rest ? t('priceHistory.gapFilled', { asset: asset.symbol })
: `${t('priceHistory.gapRemaining', { asset: asset.symbol, n: rest })} ${waarom}`);
await naWijziging(el, asset.id, 'gaps', melding);
}
async function tableRows(asset) {
const rijen = await listAllPriceHistory({ assetId: asset.id });
return koersFilter ? rijen.filter(koersFilter.match) : rijen;
}
let alleAssets = [];
const assetVan = id => alleAssets.find(a => a.id === Number(id)) || null;
const eenheidVan = asset => (asset ? priceUnitLabel(asset) : '');
function eenheidOpts(a) {
if (!a || !isMetal(a)) return [];
return [a.unit, a.subunit].filter(Boolean).map(u => ({ value: u, label: u }));
}
function momentVan(vals) {
if (vals.resolution !== 'timestamp') return String(vals.date || '');
const tijd = String(vals.time || '00:00');
return new Date(`${vals.date}T${tijd}`).toISOString();
}
async function buildTable(el, host, asset) {
const tabelAsset = () => asset;
const columns = [
{ key: 'date', label: t('priceHistory.colDate'), sortable: true, sortValue: r => r.observedAt, render: r => r.observedAt ? esc(dayNl(displayDate(r))) : dash, group: { value: r => displayDate(r), label: v => dayNl(v), order: 'desc' },
edit: { type: 'date', sticky: true, value: r => r && r.observedAt ? displayDate(r) : todayIso() } },
{ key: 'time', label: t('priceHistory.colTime'), sortable: true, sortValue: r => r.observedAt, render: r => r.resolution === 'timestamp' ? esc(localDateParts(r.observedAt).time) : dash,
edit: { type: 'time', visible: vals => vals.resolution === 'timestamp',
value: r => r && r.resolution === 'timestamp' ? localDateParts(r.observedAt).time : '00:00' } },
{ key: 'resolution', label: t('priceHistory.colResolution'), sortable: true, sortValue: r => r.resolution, render: r => r.resolution ? esc(resLabel(r.resolution)) : dash, group: { value: r => resLabel(r.resolution), label: v => v },
edit: { type: 'select', reactive: true, sticky: true, value: r => r ? r.resolution : 'day',
options: () => ENUMS.PRICE_RESOLUTION.map(v => ({ value: v, label: resLabel(v) })) } },
{ key: 'source', label: t('priceHistory.colIntegration'), sortable: true, sortValue: r => r.provider_name, render: r => r.provider_name ? esc(r.provider_name) : dash, group: { value: r => r.provider_name, label: v => v } },
{ key: 'fiatPrice', label: baseCurrency(), align: 'num', sortable: true, sortValue: r => r.fiatPrice ?? -1,
render: r => r.fiatPrice != null ? fmtBasePrice(priceFromBase(r.fiatPrice, assetVan(r.assetId))) : dash,
edit: { type: 'number', attrs: { min: 0, step: 'any' },
value: r => (r && r.fiatPrice != null ? priceFromBase(r.fiatPrice, assetVan(r.assetId)) : '') } },
{ key: 'priceUnit', label: t('priceHistory.colUnit'),
render: r => r.assetId ? esc(eenheidVan(assetVan(r.assetId))) : dash,
edit: { type: 'select', sticky: true, visible: () => isMetal(tabelAsset()),
options: () => eenheidOpts(tabelAsset()), value: () => eenheidVan(tabelAsset()) } },
{ key: 'updated', label: t('priceHistory.colUpdated'), sortable: true, sortValue: r => r.updatedAt || '', render: r => r.updatedAt ? esc(fmtDateTime(Math.floor(Date.parse(r.updatedAt) / 1000))) : dash },
];
phTable = renderTable(host, {
columns,
rows: await tableRows(asset),
idKey: 'id',
...(await tableViewCfg('prijsinfo', { key: 'date', dir: 'desc' })),
noneLabel: t('table.none'),
collapsibleGroups: true,
searchLabel: t('priceHistory.search'),
emptyText: t('priceHistory.noPoints'),
onDelete: id => onDeleteRow(el, asset, id),
onCreate: v => onCreateRow(el, asset, v),
extraActions: [{ key: 'refresh', label: t('priceHistory.refresh') }],
onAction: (key, id) => { if (key === 'refresh') onRefreshRow(el, asset, id); },
});
if (koersFocus != null) { phTable.focusRow(koersFocus); koersFocus = null; }
}
async function naRijWijziging(el, asset, locate = null) {
if (phTable) phTable.refresh(await tableRows(asset), locate != null ? { locate } : undefined);
document.dispatchEvent(new CustomEvent('price-gaps-changed'));
await renderCards(el);
await laadDetail(asset);
tekenDetailGrafiek(asset).catch(() => {});
}
async function onCreateRow(el, asset, vals) {
const id = await addManualPricePoint({
assetId: asset.id,
observedAt: momentVan(vals),
fiatPrice: vals.fiatPrice,
resolution: vals.resolution || 'day',
unit: vals.priceUnit || null,
});
await naRijWijziging(el, asset, id);
}
async function onDeleteRow(el, asset, id) {
if (!(await dialog.confirmOver({ message: t('priceHistory.confirmDeletePoint'), danger: true }))) return;
try { await deleteTableRow('PriceHistory', id); } catch { }
await naRijWijziging(el, asset);
}
async function onRefreshRow(el, asset, id) {
const row = (await tableRows(asset)).find(r => r.id === id);
if (!row) return;
const a = { id: row.assetId, symbol: row.asset_symbol };
const dateKey = row.observedAt.slice(0, 10);
try {
const bron = row.provider_key;
if (row.resolution === 'day') {
const pt = await fetchDayPoint(bron, a, dateKey);
if (pt) await upsertPriceHistory(a.id, [pt], bron, 'day');
} else if (dateKey === todayIso()) {
const sp = await fetchSpotPoint(bron, a);
if (sp) await upsertPriceHistory(a.id, [sp], bron, 'timestamp');
}
} catch { }
await naRijWijziging(el, asset);
}
+3 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:c54ac534bce5953f6b0bb207fdc1338feba7f454198f189ef3b97c5830760c48 // Generated, do not edit. sha256:c74d68370e5fcfc4f6009b2cf9bbe65c4f4f227eb216c413ea6ff5daeae0f4fa
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
import { getPref, setPref } from '../data/preferences.js'; import { getPref, setPref } from '../data/preferences.js';
import { SYSTEM_PAGE, targetHash } from '../../shared/config.js'; import { SYSTEM_PAGE, targetHash } from '../../shared/config.js';
@@ -11,6 +11,7 @@ import * as assetsPage from './assets.js';
import * as connections from './connections.js'; import * as connections from './connections.js';
import * as aiPage from './ai.js'; import * as aiPage from './ai.js';
import * as licensePage from './license.js'; import * as licensePage from './license.js';
import * as portfoliosPage from './portfolios.js';
export const title = () => t('nav.system'); export const title = () => t('nav.system');
@@ -18,6 +19,7 @@ const ROUTE = SYSTEM_PAGE.ROUTE;
const TABS = SYSTEM_PAGE.TABS; const TABS = SYSTEM_PAGE.TABS;
const PREF_SCOPE = ROUTE; const PREF_SCOPE = ROUTE;
registerSystemTab({ id: TABS.PORTFOLIOS, render: panel => portfoliosPage.render(panel) });
registerSystemTab({ id: TABS.SETTINGS, render: panel => settings.render(panel) }); registerSystemTab({ id: TABS.SETTINGS, render: panel => settings.render(panel) });
registerSystemTab({ id: TABS.ASSETS, render: panel => assetsPage.render(panel) }); registerSystemTab({ id: TABS.ASSETS, render: panel => assetsPage.render(panel) });
registerSystemTab({ id: TABS.ACCOUNTS, render: panel => accountsPage.render(panel) }); registerSystemTab({ id: TABS.ACCOUNTS, render: panel => accountsPage.render(panel) });
+3 -8
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:84e73291cc02df3176d4ba8acbc3d3f1b844080d320978a75a2af8c8bde70e30 // Generated, do not edit. sha256:064fe66c277773dbc29669a7a0cdf7abab018774c71a0f8e1d2a5cceea3a4d22
import { listSelectableAccounts } from '../data/accounts.js'; import { listSelectableAccounts } from '../data/accounts.js';
import { dayNl, localTime, toUtcIso } from '../../shared/time.js'; import { dayNl, localTime, toUtcIso } from '../../shared/time.js';
import { listAssets, listBookableAssets } from '../data/assets.js'; import { listAssets, listBookableAssets } from '../data/assets.js';
@@ -18,7 +18,6 @@ import { ensurePriceHistoryFor, historicalPointForVia, primarySourceKey } from '
import { bookDifference, acceptDifference } from '../actions/valuation-widget.js'; import { bookDifference, acceptDifference } from '../actions/valuation-widget.js';
import { TARGETS, targetHash } from '../../shared/config.js'; import { TARGETS, targetHash } from '../../shared/config.js';
import { emptyStateHtml } from '../ui/empty-state.js'; import { emptyStateHtml } from '../ui/empty-state.js';
import { capacityCfg } from '../ui/lock.js';
import { setPageHint } from '../ui/page-hint.js'; import { setPageHint } from '../ui/page-hint.js';
let tableApi = null; let tableApi = null;
@@ -146,11 +145,9 @@ async function buildTable(el) {
const toPayload = valuationPayload; const toPayload = valuationPayload;
const rijen = await rows(); const rijen = await rows();
let used = rijen.length; const reload = async () => tableApi.refresh(await rows());
const reload = async () => { const r = await rows(); used = r.length; tableApi.refresh(r); };
tableApi = renderTable(el.querySelector('#tbl'), { tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen, columns, rows: rijen,
...capacityCfg('valuations', () => used),
...(await tableViewCfg('valuations', { key: 'valuedOn', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)), ...(await tableViewCfg('valuations', { key: 'valuedOn', dir: 'desc' }, onlyAccount != null ? 'assetId' : null)),
emptyText: t('valuations.none'), emptyText: t('valuations.none'),
noneLabel: t('table.none'), noneLabel: t('table.none'),
@@ -161,9 +158,7 @@ async function buildTable(el) {
saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'), saveLabel: t('trades.save'), cancelLabel: t('trades.cancel'),
onCreate: async v => { onCreate: async v => {
const id = await createValuation(await toPayload(v, null)); const id = await createValuation(await toPayload(v, null));
const r = await rows(); tableApi.refresh(await rows(), { locate: id });
used = r.length;
tableApi.refresh(r, { locate: id });
}, },
onSave: async (id, v) => { onSave: async (id, v) => {
const ex = (await listValuations()).find(x => x.id === id); const ex = (await listValuations()).find(x => x.id === id);
+3 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:0b8a580ca944f426e2e3aa4800988bad2046937a8c0409e4dceebf09b6b3e9f7 // Generated, do not edit. sha256:00050770841127bfd8758b2ba6a84c8e7619c5e92ca5091e3c1961f13783da53
import { CONFIG } from '../shared/config.js'; import { CONFIG } from '../shared/config.js';
import { t } from './i18n/index.js'; import { t } from './i18n/index.js';
import { serverFoutTekst } from './data/_rpc.js'; import { serverFoutTekst } from './data/_rpc.js';
@@ -40,7 +40,8 @@ export async function refreshActivePortfolio() {
const r = await fetch('/api/portfolios'); const r = await fetch('/api/portfolios');
if (!r.ok) return cachedPortfolioName(); if (!r.ok) return cachedPortfolioName();
const uit = await r.json(); const uit = await r.json();
toonNaam(locked('portfolios') ? '' : uit.actief || ''); const enige = locked('portfolios') && (uit.portefeuilles || []).length < 2;
toonNaam(enige ? '' : uit.actief || '');
if (opgebouwdMet === null) opgebouwdMet = uit.actief || ''; if (opgebouwdMet === null) opgebouwdMet = uit.actief || '';
return uit.actief || ''; return uit.actief || '';
} catch { } catch {
+4 -5
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e9ea9293b00980ab0270b38c345d130a439421cd662f57bea63c105e66a1587f // Generated, do not edit. sha256:f1a362d8f5a38865a6fcd067548c9a9a2cf682e9141ad905ab0415dc770a62a9
import { CONFIG } from '../../shared/config.js'; import { CONFIG } from '../../shared/config.js';
import { activeConnection } from '../data/connections.js'; import { activeConnection } from '../data/connections.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
@@ -8,12 +8,11 @@ export async function explorerBase() {
} }
const HTTP_TIMEOUT_MS = 12000; const HTTP_TIMEOUT_MS = 12000;
const HTTP_RETRIES = 1; const HTTP_BACKOFF_MS = [2000, 5000, 10000];
const HTTP_BACKOFF_MS = 800;
export async function fetchJson(url, label, options = {}) { export async function fetchJson(url, label, options = {}) {
let laatste; let laatste;
for (let poging = 0; poging <= HTTP_RETRIES; poging++) { for (let poging = 0; poging <= HTTP_BACKOFF_MS.length; poging++) {
if (poging) await new Promise(r => setTimeout(r, HTTP_BACKOFF_MS * poging)); if (poging) await new Promise(r => setTimeout(r, HTTP_BACKOFF_MS[poging - 1]));
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS); const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS);
try { try {
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:88f1ff40757f8e5095efe909c3a45c2f437406121459c4091bd0b4d3c80eac8c // Generated, do not edit. sha256:69057ab8eda9ab21e21cc877b9070be1e08c5b047758e64ec4c5cd772fa0ac07
import { localDate } from '../../shared/time.js'; import { localDate } from '../../shared/time.js';
const DAY_MS = 86400000; const DAY_MS = 86400000;
@@ -53,7 +53,7 @@ export function heartbeat(items, todayIso, fromIso = null) {
const last = Math.max(...byDay.keys(), today); const last = Math.max(...byDay.keys(), today);
const first = Math.min(from ?? Math.min(...byDay.keys(), today) - DAY_MS, last - DAY_MS); const first = Math.min(from ?? Math.min(...byDay.keys(), today) - DAY_MS, last - DAY_MS);
const points = []; const points = [];
for (let x = first; x <= last; x += DAY_MS) { for (let x = first; x <= last + DAY_MS; x += DAY_MS) {
const day = byDay.get(x) || []; const day = byDay.get(x) || [];
const up = day.filter(i => i.dir > 0).length; const up = day.filter(i => i.dir > 0).length;
const down = day.filter(i => i.dir < 0).length; const down = day.filter(i => i.dir < 0).length;
+3 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:51b53dc3f01e74bb7f221f45ce7f2b9b6f6a164a12cedc96e33ad4f1b1b701a0 // Generated, do not edit. sha256:a36be83c24f4e2c2e73be5d6705dd33c4759901705776f21398caba72366f10a
import { currentNotifications } from '../data/notifications.js'; import { currentNotifications } from '../data/notifications.js';
import { countAiToolCallsSince } from '../data/ai-audit.js'; import { countAiToolCallsSince } from '../data/ai-audit.js';
import { getPref } from '../data/preferences.js'; import { getPref } from '../data/preferences.js';
@@ -12,6 +12,7 @@ import { CONFIG, TARGETS, NOTIFICATION_TARGETS, SOURCE_ENDPOINTS, targetHash, ta
import { registerAttention } from './attention.js'; import { registerAttention } from './attention.js';
import { appSetting, baseCurrency } from '../app-settings.js'; import { appSetting, baseCurrency } from '../app-settings.js';
import { licenseChangedSinceSeen } from '../license.js'; import { licenseChangedSinceSeen } from '../license.js';
import { placeAvailable } from '../routes.js';
async function integrationsNeedingKey() { async function integrationsNeedingKey() {
const rijen = await listProviders(); const rijen = await listProviders();
@@ -96,6 +97,7 @@ if (typeof document !== 'undefined') {
document.addEventListener('price-gaps-changed', () => { gapsCache = { at: 0, total: 0 }; }); document.addEventListener('price-gaps-changed', () => { gapsCache = { at: 0, total: 0 }; });
} }
async function priceGaps() { async function priceGaps() {
if (!placeAvailable(TARGETS.PRICE_HISTORY)) return [];
if (Date.now() - gapsCache.at > GAPS_TTL_MS) { if (Date.now() - gapsCache.at > GAPS_TTL_MS) {
try { gapsCache = { at: Date.now(), total: (await listPriceGaps()).total }; } try { gapsCache = { at: Date.now(), total: (await listPriceGaps()).total }; }
catch { return []; } catch { return []; }
+10 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:3f4bf112e28f009a738272dd18bef14d5e6736a31c2f3e1f6e434733f909d6ea // Generated, do not edit. sha256:93e679e11f5329a380a996f2d99d603c26410a0b331dfefcb1da78dc2058cc17
import { appSetting, setAppSetting } from '../app-settings.js'; import { appSetting, setAppSetting } from '../app-settings.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
import { EYE, EYE_OFF } from './icons.js'; import { EYE, EYE_OFF } from './icons.js';
@@ -37,6 +37,14 @@ async function setOn(on) {
function onActivity() { if (!isOn()) armIdle(); } function onActivity() { if (!isOn()) armIdle(); }
function onSpace(e) {
if (e.key !== ' ' || e.repeat || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
if (e.target !== document.body && e.target !== document.documentElement) return;
if (document.querySelector('.nav-edit-toggle.is-on')) return;
e.preventDefault();
setOn(!isOn());
}
export function initPrivacyBlur(hostEl) { export function initPrivacyBlur(hostEl) {
btn = document.createElement('button'); btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
@@ -52,5 +60,6 @@ export function initPrivacyBlur(hostEl) {
for (const ev of ['pointerdown', 'keydown', 'wheel', 'touchstart']) { for (const ev of ['pointerdown', 'keydown', 'wheel', 'touchstart']) {
document.addEventListener(ev, onActivity, { passive: true }); document.addEventListener(ev, onActivity, { passive: true });
} }
document.addEventListener('keydown', onSpace);
document.addEventListener('settings-changed', () => { if (!isOn()) armIdle(); }); document.addEventListener('settings-changed', () => { if (!isOn()) armIdle(); });
} }
+17 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:618e9d10819427320df9cea13ee26eca05db3121f4e69b541d2a8a431e85957a // Generated, do not edit. sha256:2cb6efb613791caa2b77487d43b44b223a6a6e006b7a374e8ed1b4c6e8f50169
import { esc } from './escape.js'; import { esc } from './escape.js';
const MOUNT = 'page-hint'; const MOUNT = 'page-hint';
@@ -47,8 +47,24 @@ function watchFit(mount) {
panel.classList.remove('is-wide'); panel.classList.remove('is-wide');
if (panel.scrollHeight > panel.clientHeight + 1) panel.classList.add('is-wide'); if (panel.scrollHeight > panel.clientHeight + 1) panel.classList.add('is-wide');
}; };
const close = () => {
const open = mount.querySelector('.hint.is-open');
open?.classList.remove('is-open');
open?.querySelector('.hint-btn')?.setAttribute('aria-expanded', 'false');
};
mount.addEventListener('pointerenter', fit); mount.addEventListener('pointerenter', fit);
mount.addEventListener('focusin', fit); mount.addEventListener('focusin', fit);
mount.addEventListener('click', (e) => {
const btn = e.target.closest?.('.hint-btn');
if (!btn) return;
const hint = btn.closest('.hint');
if (hint.classList.contains('is-open')) { close(); return; }
hint.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
fit();
});
document.addEventListener('click', (e) => { if (!mount.contains(e.target)) close(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
} }
function row(r) { function row(r) {
+3 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:06627f5d01d37034867aaf9d6b234f888eb4b99f4ecd0e53cb5df7c45bce718a // Generated, do not edit. sha256:0e4fc635bcc50c839bba37ef206c2c659c8dcc17c03278176cb0be588b69506a
export const CHEVRON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'; export const CHEVRON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
export const CHEVRON_RIGHT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'; export const CHEVRON_RIGHT = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
@@ -36,4 +36,6 @@ export const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none
export const REFRESH = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'; export const REFRESH = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
export const IMAGE = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
export const COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; export const COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
-147
View File
@@ -1,147 +0,0 @@
// Generated, do not edit. sha256:e64fde93e62c826f1ddca6217aab270e6309de4394ce5cb0e258a0e0fdb72d5c
import { t } from '../i18n/index.js';
import { listPriceSource, setPriceSourceRank, clearPriceSourceCell, confirmPriceSourceCell } from '../data/price-source.js';
import { listProviders, getProvider } from '../data/providers.js';
import { rateSourceFor } from '../price/providers.js';
import { serviceCoverage } from '../data/market.js';
import { SOURCE_CAPABILITIES, SOURCE_ENDPOINTS } from '../../shared/config.js';
import { baseCurrency, appSetting } from '../app-settings.js';
import { assetLabelHtml } from './asset-label.js';
import { esc } from './escape.js';
import * as dialog from './dialog.js';
const endpointsUnder = (p, cap) => (p.endpoints || []).filter(e => SOURCE_ENDPOINTS[cap].includes(e.capability));
const answers = (p, cap) => endpointsUnder(p, cap).length > 0;
function quoteOf(p, cap) {
const e = endpointsUnder(p, cap).find(x => x.quoteCurrency && x.quoteCurrency !== baseCurrency());
return e ? e.quoteCurrency : null;
}
const currenciesOf = p => [...new Set(SOURCE_CAPABILITIES.map(c => quoteOf(p, c)).filter(Boolean))];
export async function loadMatrixContext() {
const alle = (await Promise.all((await listProviders()).map(p => getProvider(p.key)))).filter(Boolean);
const providers = alle.filter(p => !p.isSystem);
const cell = new Map();
for (const r of await listPriceSource()) {
if (r.rank == null) continue;
cell.set(`${r.assetId}|${r.capability}|${r.providerId}`,
{ rank: r.rank, proposed: r.status === 'proposed', own: r.status === 'manual' });
}
const rateSource = new Map();
for (const p of providers) {
for (const c of currenciesOf(p)) if (!rateSource.has(c)) rateSource.set(c, await rateSourceFor(c));
}
return { alle, providers, cell, rateSource, service: await serviceContext() };
}
async function serviceContext() {
let mode = 'off';
try { mode = appSetting('market_spot'); } catch { }
if (mode === 'off') return { mode, covered: new Set() };
try {
const r = await serviceCoverage();
return { mode, covered: new Set(r.status === 'ok' ? r.assetIds : []) };
} catch { return { mode, covered: new Set() }; }
}
export function serviceOrder(chosen, mode) {
const byRank = [...chosen].sort((x, y) => x.rank - y.rank);
const order = mode === 'first'
? [...byRank.filter(c => c.own), 'service', ...byRank.filter(c => !c.own)]
: [...byRank, 'service'];
const ranks = new Map();
let service = null;
order.forEach((c, i) => { if (c === 'service') service = i + 1; else ranks.set(c.providerId, i + 1); });
return { ranks, service };
}
const marker = (ctx, v) => `<span class="mx-cur${ctx.rateSource.get(v) ? '' : ' t-neg'}">${esc(v)}</span>`;
const offTitle = p => (p.isActive ? '' : ` title="${esc(t('integrations.inactiveNoEffect'))}"`);
function kopVan(a, stand, title = true) {
if (!title && !stand) return '';
return `<div class="mx-card-head">
${title ? `<div class="wealth-head"><strong>${assetLabelHtml(a)}</strong><span class="badge">${esc(t('enums.asset_kind.' + a.kind))}</span></div>` : ''}
${stand ? `<div class="mx-stand t-caption">${esc(stand)}</div>` : ''}
</div>`;
}
export function matrixCardHtml(a, ctx, { title = true } = {}) {
const cols = ctx.providers.filter(p => (p.coverage || []).includes(a.symbol));
const svc = ctx.service && ctx.service.mode !== 'off' && ctx.service.covered.has(a.id);
if (!cols.length && !svc) {
const systeem = ctx.alle.find(p => p.isSystem && (p.coverage || []).includes(a.symbol));
const tekst = systeem ? esc(t('integrations.systemSource').replace('{naam}', systeem.name)) : esc(t('integrations.noCoverage'));
return `<div class="card mx-card">${kopVan(a, '', title)}<div class="mx-empty">${tekst}</div></div>`;
}
const svcTitle = svc ? esc(t('integrations.mxServiceHint_' + ctx.service.mode)) : '';
const naamKop = `<tr><th class="mx-lab"></th>${cols.map(p =>
`<th class="mx-col${p.isActive ? '' : ' is-off'}" title="${esc(p.isActive ? p.name : `${p.name}: ${t('integrations.inactiveNoEffect')}`)}"><span class="mx-name">${esc(p.name)}</span></th>`).join('')}${
svc ? `<th class="mx-col" title="${svcTitle}"><span class="mx-name">Atreides Services</span></th>` : ''}</tr>`;
const muntKop = `<tr class="mx-cur-row"><th class="mx-lab"></th>${cols.map(p =>
`<th>${currenciesOf(p).map(v => marker(ctx, v)).join(' ')}</th>`).join('')}${svc ? '<th></th>' : ''}</tr>`;
const head = naamKop + muntKop;
const rank = (c, p, shown = c.rank) =>
`<span class="mx-rank mx-rank-${Math.min(shown, 2)}${c.proposed ? ' is-proposed' : ''}${p.isActive ? '' : ' is-off'}"${offTitle(p)}>${shown}</span>`;
let open = 0, voorstellen = 0;
const leeg = [];
const rows = SOURCE_CAPABILITIES.filter(cap => cols.some(p => answers(p, cap)) || (svc && cap === 'spot'));
const body = rows.map(cap => {
const metDienst = svc && cap === 'spot';
const volgorde = metDienst
? serviceOrder(cols.map(p => ({ providerId: p.id, ...ctx.cell.get(`${a.id}|${cap}|${p.id}`) }))
.filter(c => c.rank != null), ctx.service.mode)
: null;
if (!metDienst && !cols.some(p => ctx.cell.has(`${a.id}|${cap}|${p.id}`))) leeg.push(t('integrations.sourceCap.' + cap));
const dienstCel = svc
? (metDienst
? `<td class="mx-svc" title="${svcTitle}"><span class="mx-rank mx-rank-${Math.min(volgorde.service, 2)} is-service">${volgorde.service}</span></td>`
: `<td class="mx-na"></td>`)
: '';
const cells = cols.map(p => {
if (!answers(p, cap)) return `<td class="mx-na"></td>`;
const c = ctx.cell.get(`${a.id}|${cap}|${p.id}`);
const valuta = quoteOf(p, cap);
if (valuta && !ctx.rateSource.get(valuta) && !c) {
return `<td class="mx-na" title="${esc(t('integrations.noRateSourceHint', { valuta }))}"></td>`;
}
if (!c) open++;
else if (c.proposed) voorstellen++;
const inner = c ? rank(c, p, volgorde ? volgorde.ranks.get(p.id) : c.rank) : `<span class="mx-pick">+</span>`;
return `<td class="mx-cell" data-a="${a.id}" data-cap="${cap}" data-p="${p.id}" data-has="${c ? 1 : 0}" data-proposed="${c && c.proposed ? 1 : 0}">${inner}</td>`;
}).join('');
return `<tr><th class="mx-lab">${esc(t('integrations.sourceCap.' + cap))}</th>${cells}${dienstCel}</tr>`;
}).join('');
const stand = [
leeg.length ? t('integrations.mxToSetUp', { wat: leeg.join(' en ') }) : '',
voorstellen ? t(voorstellen === 1 ? 'integrations.mxProposalsOne' : 'integrations.mxProposalsMany', { n: voorstellen }) : '',
open ? t(open === 1 ? 'integrations.mxOpenOne' : 'integrations.mxOpenMany', { n: open }) : t('integrations.mxAllChosen'),
].filter(Boolean).join(' · ');
return `<div class="card mx-card">${kopVan(a, stand.charAt(0).toUpperCase() + stand.slice(1), title)}<div class="mx-wrap"><table class="mx-table"><thead>${head}</thead><tbody>${body}</tbody></table></div></div>`;
}
export function wireMatrix(host, redraw) {
host.querySelectorAll('.mx-cell').forEach(td => td.onclick = async () => {
const d = td.dataset;
const a = Number(d.a), p = Number(d.p);
try {
if (d.has === '1' && d.proposed === '1') await confirmPriceSourceCell(a, d.cap, p);
else if (d.has === '1') await clearPriceSourceCell(a, d.cap, p);
else await setPriceSourceRank(a, d.cap, p);
document.dispatchEvent(new CustomEvent('providers-changed'));
} catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
await redraw();
});
}
export function matrixLegendRows(ctx) {
const uit = ctx.providers.some(p => !p.isActive)
? [{ sample: '<span class="mx-rank mx-rank-1 is-off" style="--mx-rank:1.1rem">1</span>', text: esc(t('integrations.inactiveHint')) }]
: [];
const munten = [...ctx.rateSource].map(([v, via]) => ({
sample: marker(ctx, v),
text: esc(via ? t('integrations.convertedVia', { naam: via.name, valuta: v, munt: baseCurrency() }) : t('integrations.noRateSource', { valuta: v })),
}));
return [...uit, ...munten];
}
+8 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:79d0944a46421ed3e71c6aff535ca9c3c77b373253595260dd7e749f587d9edd // Generated, do not edit. sha256:1e2f6b41323ddf9ad9f82e4a351a8a2bf38460efa6a6ea846e58268ba7ab38e4
import { CONFIG } from '../../shared/config.js'; import { CONFIG } from '../../shared/config.js';
import { windowTwr, windowFactors } from './performance.js'; import { windowTwr, windowFactors } from './performance.js';
import { dailyResults, meetVenster, rollingWindows, seriesFor, windowResult } from './series.js'; import { dailyResults, meetVenster, rollingWindows, seriesFor, windowResult } from './series.js';
@@ -32,6 +32,13 @@ export function returnLine(input = {}, venster = null) {
return { amount: windowResult(input, venster), pct: windowTwr(input, venster), points }; return { amount: windowResult(input, venster), pct: windowTwr(input, venster), points };
} }
export function flatLine(days, today) {
const n = Math.max(2, days);
const end = Date.parse(`${today}T00:00:00Z`);
const labels = Array.from({ length: n }, (_, k) => new Date(end - (n - 1 - k) * 86_400_000).toISOString().slice(0, 10));
return { amount: 0, pct: 0, labels, points: labels.map((_, i) => ({ i, cumulative: 0, day: 0, pct: 0 })) };
}
export function contributorsPerDay(reeks = {}, ids = [], venster = null, n = 3) { export function contributorsPerDay(reeks = {}, ids = [], venster = null, n = 3) {
const out = new Map(); const out = new Map();
if (!venster || !ids || ids.length < 2) return out; if (!venster || !ids || ids.length < 2) return out;
+22 -5
View File
@@ -1,5 +1,5 @@
// Generated, do not edit. sha256:ae0f941ec7a830f9f94ae85e37de1157b6e15f16cc2e9dc3f0c5c7243d3eaafd // Generated, do not edit. sha256:dbfabff3706a8f2f1755ee61d42f71fcabb5b42dd0ab60143a9e2b568cf13043
import { listAssets } from '../data/assets.js'; import { listAssets, listBookableAssets } from '../data/assets.js';
import { listAccounts } from '../data/accounts.js'; import { listAccounts } from '../data/accounts.js';
import { getPref, setPref } from '../data/preferences.js'; import { getPref, setPref } from '../data/preferences.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
@@ -11,8 +11,12 @@ import { rangeLabel, rangeCycleButton, wireRangeCycle } from '../ui/range-cycle.
import { isOwnWealth } from '../../shared/wealth-scope.js'; import { isOwnWealth } from '../../shared/wealth-scope.js';
import { ENUMS, TARGETS, targetHash } from '../../shared/config.js'; import { ENUMS, TARGETS, targetHash } from '../../shared/config.js';
import { dagreeksVanNu } from '../wealth/series.js'; import { dagreeksVanNu } from '../wealth/series.js';
import { periodChoices } from '../wealth/return-view.js'; import { periodChoices, flatLine } from '../wealth/return-view.js';
import { lineForData, drawLine } from '../wealth/return-dialog.js'; import { lineForData, drawLine } from '../wealth/return-dialog.js';
import { drawReturnChart } from '../wealth/return-chart.js';
import { localDateNl } from '../../shared/time.js';
const localTodayIso = () => new Date().toISOString().slice(0, 10);
const PREF_SCOPE = 'widget.return'; const PREF_SCOPE = 'widget.return';
const RANGES = Object.freeze({ day: 1, week: 7, month: 30, year: 365, all: 0 }); const RANGES = Object.freeze({ day: 1, week: 7, month: 30, year: 365, all: 0 });
@@ -62,14 +66,27 @@ export async function renderReturnWidget(host, instance) {
const held = assets.filter(a => (reeks.perAsset?.get(a.id) || []).some(v => v > 0)); const held = assets.filter(a => (reeks.perAsset?.get(a.id) || []).some(v => v > 0));
const periods = periodChoices(reeks.dagen?.length || 0, null); const periods = periodChoices(reeks.dagen?.length || 0, null);
const empty = !reeks.dagen?.length;
const draw = async () => { const draw = async () => {
const g = ++gen; const g = ++gen;
const btn = host.querySelector('.range-cycle-btn'); const btn = host.querySelector('.range-cycle-btn');
if (btn) btn.textContent = rangeLabel(range); if (btn) btn.textContent = rangeLabel(range);
if (empty) {
const flat = flatLine(RANGES[range] || RANGES.month, localTodayIso());
amountEl.innerHTML = `<span class="val">${esc(fmtBaseSigned(0, colouredSigns()))}</span>`;
pctEl.innerHTML = `<span class="delta ${pctTone(0)}">${esc(fmtPct(0, { signs: colouredSigns() }))}</span>`;
chart?.destroy();
const canvas = host.querySelector('[data-return-chart]');
const c = canvas ? await drawReturnChart(canvas, flat.points, flat.labels.map(d => localDateNl(`${d}T00:00:00Z`))).catch(() => null) : null;
if (g !== gen || !host.isConnected) { c?.destroy(); return; }
chart = c;
return;
}
const venster = periods.find(p => p.key === PERIOD_OF[range])?.venster || null; const venster = periods.find(p => p.key === PERIOD_OF[range])?.venster || null;
const line = lineForData({ reeks, assets: held, data, venster }); const line = lineForData({ reeks, assets: held, data, venster });
const tone = line.amount > 0.005 ? ' t-pos' : line.amount < -0.005 ? ' t-neg' : '';
amountEl.innerHTML = line.amount == null ? '-' amountEl.innerHTML = line.amount == null ? '-'
: `<span class="val">${esc(fmtBaseSigned(line.amount, 'both'))}</span>`; : `<span class="val${tone}">${esc(fmtBaseSigned(line.amount, colouredSigns()))}</span>`;
pctEl.innerHTML = line.pct == null ? '' : `<span class="delta ${pctTone(line.pct, { fine: range === 'day' })}">${esc(fmtPct(line.pct, { fine: range === 'day', signs: colouredSigns() }))}</span>`; pctEl.innerHTML = line.pct == null ? '' : `<span class="delta ${pctTone(line.pct, { fine: range === 'day' })}">${esc(fmtPct(line.pct, { fine: range === 'day', signs: colouredSigns() }))}</span>`;
chart?.destroy(); chart?.destroy();
chart = null; chart = null;
@@ -94,7 +111,7 @@ export async function renderReturnWidget(host, instance) {
} }
export async function renderReturnSettings(host, instance, commit) { export async function renderReturnSettings(host, instance, commit) {
const assets = (await listAssets()).sort(byKindAndLabel); const assets = (await listBookableAssets()).sort(byKindAndLabel);
const current = dataOf(instance); const current = dataOf(instance);
const opt = (value, label) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(label)}</option>`; const opt = (value, label) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(label)}</option>`;
const kinds = ENUMS.ASSET_KIND.filter(k => assets.some(a => a.kind === k)); const kinds = ENUMS.ASSET_KIND.filter(k => assets.some(a => a.kind === k));
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "assetapp", "name": "assetapp",
"version": "0.3.2", "version": "0.3.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "assetapp", "name": "assetapp",
"version": "0.3.2", "version": "0.3.3",
"dependencies": { "dependencies": {
"@noble/curves": "2.4.0" "@noble/curves": "2.4.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "assetapp", "name": "assetapp",
"version": "0.3.2", "version": "0.3.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -4
View File
@@ -1,7 +1,6 @@
// Generated, do not edit. sha256:37f1d463b03d2b5d702238cb632740786fc53e2072e4691719327d700c59d56c // Generated, do not edit. sha256:fd977989705c07dc15f67feef3edd971f7d3fa3c94e064e7618ee1e171f4ce7e
import { capacityReached } from '../license.mjs'; import { capacityReached } from '../license.mjs';
import * as db from '../db.js'; import * as db from '../db.js';
import { counts } from './counts.js';
import { RATE_ONLY_ASSET } from './_shared.js'; import { RATE_ONLY_ASSET } from './_shared.js';
import { getBaseCurrency } from './portfolio.js'; import { getBaseCurrency } from './portfolio.js';
@@ -15,9 +14,7 @@ function assetCount() {
} }
export function currentCount(soort) { export function currentCount(soort) {
const c = counts();
if (soort === 'assets') return assetCount(); if (soort === 'assets') return assetCount();
if (soort === 'valuations') return c.valuations;
if (soort === 'fiatAccounts') return fiatAccountCount(); if (soort === 'fiatAccounts') return fiatAccountCount();
throw new Error(`No counter for capacity '${soort}' yet.`); throw new Error(`No counter for capacity '${soort}' yet.`);
} }
+3 -1
View File
@@ -1,7 +1,8 @@
// Generated, do not edit. sha256:ebad02dba29ae50bcbaf9c21bb59936cf43f6be868e86ac00d0e6055f404b26f // Generated, do not edit. sha256:36119ec87b42a3428d74ef9e16a174eb6f028b536ffdcea60da0bd3e970bac77
import * as db from '../db.js'; import * as db from '../db.js';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js'; import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
import { fail } from '../../shared/app-error.js'; import { fail } from '../../shared/app-error.js';
import { hasFeature } from '../license.mjs';
import { eventsNetByAccount } from './events.js'; import { eventsNetByAccount } from './events.js';
import { trackUpdate, trackUpdateWhere, trackDelete, trackDeleteWhere, withOrigin } from './change-log.js'; import { trackUpdate, trackUpdateWhere, trackDelete, trackDeleteWhere, withOrigin } from './change-log.js';
@@ -34,6 +35,7 @@ export function mergeCheck(survivorId, victimId) {
} }
export async function mergeAccounts(survivorId, victimId) { export async function mergeAccounts(survivorId, victimId) {
if (!hasFeature('accountMerge')) fail('accounts.mergePaid', null, 'Merging accounts belongs to the full edition.');
survivorId = Number(survivorId); victimId = Number(victimId); survivorId = Number(survivorId); victimId = Number(victimId);
const chk = mergeCheck(survivorId, victimId); const chk = mergeCheck(survivorId, victimId);
if (!chk.ok) fail(`accounts.mergeReason_${chk.reason}`, null, `Merge not possible (${chk.reason}).`); if (!chk.ok) fail(`accounts.mergeReason_${chk.reason}`, null, `Merge not possible (${chk.reason}).`);
+7 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:ebca807e87ebabedc0e0ca214ca3bec781ace05d2be4dc4ed8d664c767fe1d63 // Generated, do not edit. sha256:1cab6c030597184175a0db5bb668aea9074d983a3f04b5255055f6a7df3c07d8
import * as db from '../db.js'; import * as db from '../db.js';
import { DASHBOARD } from '../../shared/config.js'; import { DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js'; import { toSearchName } from '../../shared/search-name.js';
@@ -84,16 +84,20 @@ function assertWidgetType(type) {
} }
} }
export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null }) { export async function addWidget(profileId, { instanceKey, type, gridX = 0, gridY = 0, assetId = null, settings = null, spanWidth = null }) {
if (!db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId])) { if (!db.one(`SELECT id FROM DashboardProfile WHERE id = ?`, [profileId])) {
fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`); fail('dashboard.unknownProfile', { id: profileId }, `Dashboard profile #${profileId} does not exist.`);
} }
assertWidgetType(type); assertWidgetType(type);
const span = spanWidth ?? DASHBOARD.WIDGETS[type].defaultSpan;
if (!Number.isInteger(span) || span < 1 || span > DASHBOARD.GRID_COLUMNS) {
throw new Error(`Widget width ${spanWidth} is not 1 to ${DASHBOARD.GRID_COLUMNS} columns.`);
}
const key = instanceKey || type; const key = instanceKey || type;
if (db.one(`SELECT 1 AS x FROM DashboardWidget WHERE profileId = ? AND instanceKey = ?`, [profileId, key])) return false; if (db.one(`SELECT 1 AS x FROM DashboardWidget WHERE profileId = ? AND instanceKey = ?`, [profileId, key])) return false;
const json = settings && typeof settings === 'object' && Object.keys(settings).length ? JSON.stringify(settings) : null; const json = settings && typeof settings === 'object' && Object.keys(settings).length ? JSON.stringify(settings) : null;
db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings) db.run(`INSERT INTO DashboardWidget (profileId, assetId, instanceKey, gridX, gridY, spanWidth, spanHeight, type, settings)
VALUES (?,?,?,?,?,?,NULL,?,?)`, [profileId, assetId, key, gridX, gridY, DASHBOARD.WIDGETS[type].defaultSpan, type, json]); VALUES (?,?,?,?,?,?,NULL,?,?)`, [profileId, assetId, key, gridX, gridY, span, type, json]);
await db.persist(); await db.persist();
return true; return true;
} }
+5 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:329990ff910102e84cb67048624a913db4948fa6ef673de3eb40c65c633864ba // Generated, do not edit. sha256:bf3b89b4669e19b78df9f4741f695e6025ab616c2c0a5f205b84e84c1ce21f92
import * as db from '../db.js'; import * as db from '../db.js';
import { ASSET_DEFINITIONS, DASHBOARD } from '../../shared/config.js'; import { ASSET_DEFINITIONS, DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js'; import { toSearchName } from '../../shared/search-name.js';
@@ -38,6 +38,8 @@ async function ensureAccount({ name, kind }) {
return createAccount({ name, kind, isOwnCustody: own, scanEnabled: false, taxCategory: null }); return createAccount({ name, kind, isOwnCustody: own, scanEnabled: false, taxCategory: null });
} }
const SETUP_SPAN = 4;
function placeInFreeSpace(rows, items) { function placeInFreeSpace(rows, items) {
const cols = DASHBOARD.GRID_COLUMNS; const cols = DASHBOARD.GRID_COLUMNS;
const heightOf = (type, h) => (h > 0 ? h : DASHBOARD.WIDGETS[type]?.defaultH || 1); const heightOf = (type, h) => (h > 0 ? h : DASHBOARD.WIDGETS[type]?.defaultH || 1);
@@ -47,7 +49,7 @@ function placeInFreeSpace(rows, items) {
for (let c = r.gridX; c < Math.min(cols, r.gridX + r.spanWidth); c++) bottom[c] = Math.max(bottom[c], end); for (let c = r.gridX; c < Math.min(cols, r.gridX + r.spanWidth); c++) bottom[c] = Math.max(bottom[c], end);
} }
return items.map(({ prefer, ...w }) => { return items.map(({ prefer, ...w }) => {
const span = DASHBOARD.WIDGETS[w.type].defaultSpan; const span = w.spanWidth ?? DASHBOARD.WIDGETS[w.type].defaultSpan;
let bestX = 0, bestY = Infinity; let bestX = 0, bestY = Infinity;
for (let x = 0; x + span <= cols; x++) { for (let x = 0; x + span <= cols; x++) {
const y = Math.max(...bottom.slice(x, x + span)); const y = Math.max(...bottom.slice(x, x + span));
@@ -74,7 +76,7 @@ export async function completeSetup({ assets = [], persona = 'broad', accounts =
const add = []; const add = [];
if (widgets.includes('standing')) add.push({ type: 'standing', prefer: 4 }); if (widgets.includes('standing')) add.push({ type: 'standing', prefer: 4 });
if (widgets.includes('signal') && persona === 'bitcoin') add.push({ type: 'signal', prefer: 4 }); if (widgets.includes('signal') && persona === 'bitcoin') add.push({ type: 'signal', prefer: 4 });
if (widgets.includes('spot')) assetIds.forEach(id => add.push({ type: 'spot', instanceKey: `spot-${id}`, assetId: id, prefer: 0, settings: { momentum: false } })); if (widgets.includes('spot')) assetIds.forEach(id => add.push({ type: 'spot', instanceKey: `spot-${id}`, assetId: id, prefer: 0, settings: { momentum: false }, spanWidth: SETUP_SPAN }));
let widgetCount = 0; let widgetCount = 0;
for (const w of placeInFreeSpace(listWidgets(profileId), add)) if (await addWidget(profileId, w)) widgetCount++; for (const w of placeInFreeSpace(listWidgets(profileId), add)) if (await addWidget(profileId, w)) widgetCount++;
+1 -7
View File
@@ -1,9 +1,8 @@
// Generated, do not edit. sha256:297c7a1d9ed86e15053cd37bb7126f5fb5b54d94b67e8da901fb66f370ca9301 // Generated, do not edit. sha256:bd828bb299e2fd6141c6257cc9c20d5c7b2b7cc88932040bd9b98ce4dfeddd09
import * as db from '../db.js'; import * as db from '../db.js';
import { ENUMS } from '../../shared/config.js'; import { ENUMS } from '../../shared/config.js';
import { nowUtcIso } from '../../shared/time.js'; import { nowUtcIso } from '../../shared/time.js';
import { assertEnum } from './_shared.js'; import { assertEnum } from './_shared.js';
import { capacityReachedFor } from './_limits.js';
import { fail } from '../../shared/app-error.js'; import { fail } from '../../shared/app-error.js';
import { eventsCountByAccount, eventsNetByAccount } from './events.js'; import { eventsCountByAccount, eventsNetByAccount } from './events.js';
import { trackDelete, trackUpdate } from './change-log.js'; import { trackDelete, trackUpdate } from './change-log.js';
@@ -123,11 +122,6 @@ export async function deleteValuation(id) {
} }
export async function createValuation(v) { export async function createValuation(v) {
assertEnum('source', ENUMS.VALUATION_SOURCE, v.source); assertEnum('source', ENUMS.VALUATION_SOURCE, v.source);
const max = capacityReachedFor('valuations');
if (max !== null) {
fail('valuations.maxFree', { max },
`The free version holds ${max} balance(s). A licence lifts that; nothing is removed in the meantime.`);
}
db.run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source) db.run(`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?)`,
[v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source]); [v.assetId, v.valuedOn, v.accountId ?? null, v.balance, v.fiatRate, v.rateAt, v.source]);
+11 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:95f28c26a77b79beac5f8c6818bd25243362641c1d8549949427a847e4f810f9 // Generated, do not edit. sha256:232164f95c0eb5137f0c20ab218bdb9ffd81d26142889e12d2caf11dfbc181a3
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
export const BUSY_TIMEOUT_MS = 5000; export const BUSY_TIMEOUT_MS = 5000;
@@ -30,6 +30,16 @@ export function withOtherDatabase(pad, fn) {
} }
} }
export function peekDatabase(pad, fn) {
const peek = new DatabaseSync(pad, { readOnly: true });
const all = (sql, params = []) => peek.prepare(sql).all(...bind(params)).map(rij => ({ ...rij }));
try {
return fn({ all, one: (sql, params = []) => all(sql, params)[0] ?? null });
} finally {
peek.close();
}
}
export function closeDatabase() { export function closeDatabase() {
if (!db) return; if (!db) return;
db.close(); db.close();
+5 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:52135637bc4779b2b5f755ec50fedf41d2fa7b65d90a35f911c4e68f0b093874 // Generated, do not edit. sha256:9946c137bb48925908ab17af61fbcdfb543a87d4be25e34812a95d4084af9425
import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS, DASHBOARD } from '../../shared/config.js'; import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, SOURCE_ENDPOINTS, DASHBOARD } from '../../shared/config.js';
import { toSearchName } from '../../shared/search-name.js'; import { toSearchName } from '../../shared/search-name.js';
import { run, one, all, lastId } from './conn.mjs'; import { run, one, all, lastId } from './conn.mjs';
@@ -176,7 +176,10 @@ export function seedDashboardProfile() {
return true; return true;
} }
const FRESH_VALUES = Object.freeze({ app: Object.freeze({ market_spot: 'first' }) }); const FRESH_VALUES = Object.freeze({
app: Object.freeze({ market_spot: 'first' }),
ai: Object.freeze({ enabled: 0 }),
});
export function seedPreferences() { export function seedPreferences() {
for (const [scope, keys] of Object.entries(PREFERENCES)) { for (const [scope, keys] of Object.entries(PREFERENCES)) {
+131
View File
@@ -0,0 +1,131 @@
// Generated, do not edit. sha256:c7cd149bf7a3cd10c21ce3f8a14833aa6c606b7953811447bfbf09af17e22088
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG } from '../shared/config.js';
import { priceFromTotal } from '../shared/units.js';
import { toSearchName } from '../shared/search-name.js';
import { initDatabase, closeDatabase, one } from './db.js';
import { portfolioDir, listPortfolios } from './portfolios.mjs';
import { hasFeature } from './license.mjs';
import { completeSetup } from './data/onboarding.js';
import { createEvent } from './data/events.js';
import { createValuation } from './data/valuations.js';
export const DEMO_MONTHS = 30;
const BTC_EUR = {
'2023-01': 21000, '2023-04': 27000, '2023-07': 26500, '2023-10': 32000,
'2024-01': 39000, '2024-02': 48000, '2024-03': 63000, '2024-04': 59000, '2024-05': 62000, '2024-06': 58000,
'2024-07': 59000, '2024-08': 54000, '2024-09': 57000, '2024-10': 63000, '2024-11': 88000, '2024-12': 92000,
'2025-01': 97000, '2025-02': 84000, '2025-03': 78000, '2025-04': 82000, '2025-05': 92000, '2025-06': 91000,
'2025-07': 100000, '2025-08': 97000, '2025-09': 96000, '2025-10': 98000, '2025-11': 82000, '2025-12': 76000,
'2026-01': 79000, '2026-02': 74000, '2026-03': 72000, '2026-04': 78000, '2026-05': 84000, '2026-06': 88000,
'2026-07': 92000, '2026-08': 90000, '2026-09': 94000, '2026-10': 96000,
};
const MONTHS = Object.keys(BTC_EUR).sort();
const SAT = 1e8;
const CENT = 100;
const ACCOUNTS = Object.freeze({
exchange: { name: 'Exchange', kind: 'exchange' },
wallet: { name: 'Hardware wallet', kind: 'wallet' },
bank: { name: 'Bank', kind: 'bank' },
});
const iso = (y, m, day) => new Date(Date.UTC(y, m, day, 10, 0, 0)).toISOString();
function priceOn(at) {
const key = at.slice(0, 7);
const idx = MONTHS.findIndex(k => k >= key);
const here = BTC_EUR[MONTHS[idx < 0 ? MONTHS.length - 1 : idx]];
const next = BTC_EUR[MONTHS[Math.min((idx < 0 ? MONTHS.length - 1 : idx) + 1, MONTHS.length - 1)]];
const frac = (Number(at.slice(8, 10)) - 1) / 30;
return Math.round(here + (next - here) * frac);
}
export function demoRows(today = new Date()) {
const rows = [];
const pos = { exchange: 0, wallet: 0, bank: 0 };
const nu = today.toISOString();
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - (DEMO_MONTHS - 1), 1));
const add = (r) => { if (r.at <= nu) rows.push(r); return r.at <= nu; };
for (let i = 0; i < DEMO_MONTHS; i++) {
const y = start.getUTCFullYear(), m = start.getUTCMonth() + i;
if (i === 0 && add({ kind: 'event', type: 'buy', asset: 'EUR', to: 'bank', at: iso(y, m, 1), amount: 4000 * CENT, fiatPrice: 1 })) pos.bank += 4000 * CENT;
if (add({ kind: 'event', type: 'buy', asset: 'EUR', to: 'bank', at: iso(y, m, 1), amount: 900 * CENT, fiatPrice: 1 })) pos.bank += 900 * CENT;
for (const day of [2, 9, 16, 23]) {
const at = iso(y, m, day);
const price = priceOn(at);
const fee = 0.4;
const amount = Math.round((75 - fee) / price * SAT);
if (add({ kind: 'event', type: 'buy', asset: 'BTC', to: 'exchange', at, amount, fiatPrice: priceFromTotal(75, amount, 8, fee, 'buy'), fiatFee: fee })) {
pos.exchange += amount;
}
}
if (i % 4 === 3) {
const amount = pos.exchange - 100_000;
if (amount > 0 && add({ kind: 'event', type: 'transfer', asset: 'BTC', from: 'exchange', to: 'wallet', at: iso(y, m, 26), amount })) {
pos.exchange -= amount; pos.wallet += amount;
}
}
if (i === 20) {
const amount = 2_000_000;
if (add({ kind: 'event', type: 'transfer', asset: 'BTC', from: 'wallet', to: 'exchange', at: iso(y, m, 10), amount })) {
pos.wallet -= amount; pos.exchange += amount;
const at = iso(y, m, 11);
if (add({ kind: 'event', type: 'sell', asset: 'BTC', from: 'exchange', at, amount, fiatPrice: priceOn(at), fiatFee: 2 })) pos.exchange -= amount;
}
}
if (m % 3 === 2) {
const amount = Math.round(pos.bank * 0.015 / 4);
if (amount > 0 && add({ kind: 'event', type: 'interest', asset: 'EUR', to: 'bank', at: iso(y, m, 28), amount, fiatPrice: 1 })) pos.bank += amount;
}
const peil = (account, asset) => {
const at = new Date(Date.UTC(y, m, 28, 22, 0, 0)).toISOString();
add({ kind: 'valuation', asset, account, at, valuedOn: at.slice(0, 10), balance: pos[account],
fiatRate: asset === 'EUR' ? 1 : priceOn(at) });
};
if (m % 6 === 5) { peil('bank', 'EUR'); if (pos.wallet > 0) peil('wallet', 'BTC'); }
if (m % 12 === 11 && pos.exchange > 0) peil('exchange', 'BTC');
}
return rows;
}
export async function seedDemo(today = new Date()) {
const accounts = Object.values(ACCOUNTS);
await completeSetup({ assets: ['BTC'], persona: 'broad', accounts, widgets: ['standing', 'spot'] });
const assetId = (symbol) => one(`SELECT id FROM Asset WHERE symbol = ?`, [symbol]).id;
const accountId = (key) => one(`SELECT id FROM Account WHERE searchName = ?`, [toSearchName(ACCOUNTS[key].name)]).id;
const ids = { BTC: assetId('BTC'), EUR: assetId('EUR') };
for (const r of demoRows(today)) {
if (r.kind === 'event') {
await createEvent({
assetId: ids[r.asset], type: r.type, occurredAt: r.at, amount: r.amount, fiatPrice: r.fiatPrice ?? null,
fiatFee: r.fiatFee ?? null, fromAccountId: r.from ? accountId(r.from) : null,
toAccountId: r.to ? accountId(r.to) : null, source: 'manual',
});
} else {
await createValuation({ assetId: ids[r.asset], accountId: accountId(r.account), balance: r.balance,
fiatRate: r.fiatRate, valuedOn: r.valuedOn, rateAt: r.at, source: 'manual' });
}
}
}
export async function createDemoPortfolio(today = new Date()) {
const map = portfolioDir(CONFIG.PORTFOLIO.DEMO_NAME);
mkdirSync(map, { recursive: true });
initDatabase(join(map, 'db.sqlite'));
try {
await seedDemo(today);
} finally {
closeDatabase();
}
}
export async function ensureDemo(layout) {
if (!layout.fresh || hasFeature('portfolios') || listPortfolios().some(p => p.demo)) return false;
await createDemoPortfolio();
return true;
}
+10 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:b9a3a6b8f1e1fdbee54a4d9e67411a1659c1ea24437a706bbf1a46f443c3b26d // Generated, do not edit. sha256:a2dbaa6a0b5bd22d02f0f5bce9b00466d953e01821cc546e4e9315325da7765a
import { createServer as createHttpServer } from 'node:http'; import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https'; import { createServer as createHttpsServer } from 'node:https';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
@@ -8,6 +8,8 @@ import { initDatabase, one } from './db.js';
import { getProvider } from './data/providers.js'; import { getProvider } from './data/providers.js';
import { moveProviderKeys } from './keys.mjs'; import { moveProviderKeys } from './keys.mjs';
import { prepareLayout } from './portfolios.mjs'; import { prepareLayout } from './portfolios.mjs';
import { ensureDemo } from './demo.mjs';
import { CONFIG } from '../shared/config.js';
import { licenseState, appVersion, hasFeature } from './license.mjs'; import { licenseState, appVersion, hasFeature } from './license.mjs';
import { getPref } from './data/preferences.js'; import { getPref } from './data/preferences.js';
import { adoptInstallLocale } from './installation.mjs'; import { adoptInstallLocale } from './installation.mjs';
@@ -29,7 +31,13 @@ function openInBrowser(url) {
let credentials, tls, binding, dbRegel, portefeuilleRegel; let credentials, tls, binding, dbRegel, portefeuilleRegel;
try { try {
portefeuilleRegel = prepareLayout().regel; const layout = prepareLayout();
portefeuilleRegel = layout.regel;
try {
if (await ensureDemo(layout)) portefeuilleRegel += ` | demo: ${CONFIG.PORTFOLIO.DEMO_NAME} aangemaakt`;
} catch (e) {
console.error(`[assetapp] demo niet aangemaakt: ${e.message}`);
}
credentials = await loadCredentials(); credentials = await loadCredentials();
tls = await loadTlsOptions(); tls = await loadTlsOptions();
binding = resolveBinding({ tls: Boolean(tls), credentials, host: process.env.ASSETAPP_HOST || null }); binding = resolveBinding({ tls: Boolean(tls), credentials, host: process.env.ASSETAPP_HOST || null });
+33 -4
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:1c5123afde8b42e8b0c7944a90a9a3952b38ed44748eb715f09bb8aa35f593d5 // Generated, do not edit. sha256:a56d0789a1d23362a10302d4a28be7384372f8aa39766c83b8df92fa4456f11e
import { mkdir, readdir, rename, rm, readFile, writeFile, copyFile, stat } from 'node:fs/promises'; import { mkdir, readdir, rename, rm, readFile, writeFile, copyFile, stat } from 'node:fs/promises';
import { join, sep, resolve } from 'node:path'; import { join, sep, resolve } from 'node:path';
@@ -6,7 +6,7 @@ import {
installDir, portfolioDir, archiveDir, assertValidName, sameName, installDir, portfolioDir, archiveDir, assertValidName, sameName,
activeName, writeActiveName, listPortfolios, activeName, writeActiveName, listPortfolios,
} from './portfolios.mjs'; } from './portfolios.mjs';
import { run } from './db/conn.mjs'; import { run, peekDatabase } from './db/conn.mjs';
import { createDatabase } from './db.js'; import { createDatabase } from './db.js';
import { CONFIG } from '../shared/config.js'; import { CONFIG } from '../shared/config.js';
import { fail } from '../shared/app-error.js'; import { fail } from '../shared/app-error.js';
@@ -45,7 +45,7 @@ export async function createPortfolio(naam, { kopieVan = null, baseCurrency = nu
if (kopieVan && baseCurrency) { if (kopieVan && baseCurrency) {
throw new Error('A copy keeps the unit of account of its source; do not pass baseCurrency with kopieVan.'); throw new Error('A copy keeps the unit of account of its source; do not pass baseCurrency with kopieVan.');
} }
const max = capacityReached('portfolios', listPortfolios().length); const max = capacityReached('portfolios', listPortfolios().filter(p => !p.demo).length);
if (max !== null) { if (max !== null) {
fail('portfolios.maxFree', { max }, fail('portfolios.maxFree', { max },
`The free version holds ${max} portfolio(s). A licence lifts that; nothing is removed in the meantime.`); `The free version holds ${max} portfolio(s). A licence lifts that; nothing is removed in the meantime.`);
@@ -155,11 +155,40 @@ export async function purgeArchived(naam) {
return { naam, map }; return { naam, map };
} }
function keyFigures(naam) {
const pad = join(portfolioDir(naam), 'db.sqlite');
const perSoort = rijen => Object.fromEntries(rijen.map(r => [r.kind, r.n]));
try {
return peekDatabase(pad, ({ one, all }) => {
const backup = one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'last_backup_at'`);
const eerste = [
one(`SELECT MIN(occurredAt) AS d FROM Event WHERE type <> 'reservation'`).d,
one(`SELECT MIN(rateAt) AS d FROM Valuation WHERE accountId IS NOT NULL`).d,
].filter(Boolean).sort()[0] || null;
const portfolio = one(`SELECT baseCurrency, createdAt FROM Portfolio WHERE id = 1`);
return {
munt: portfolio?.baseCurrency ?? null,
aangemaakt: portfolio?.createdAt ?? null,
boekingen: one(`SELECT COUNT(*) AS n FROM Event WHERE type <> 'reservation'`).n,
balansen: one(`SELECT COUNT(*) AS n FROM Valuation`).n,
assets: perSoort(all(`SELECT kind, COUNT(*) AS n FROM Asset WHERE id IN (
SELECT assetId FROM Event WHERE type <> 'reservation' UNION SELECT assetId FROM Valuation)
GROUP BY kind`)),
accounts: perSoort(all(`SELECT kind, COUNT(*) AS n FROM Account WHERE isActive = 1 GROUP BY kind`)),
eersteJaar: eerste ? Number(eerste.slice(0, 4)) : null,
laatsteBackup: (backup && JSON.parse(backup.value)) || null,
};
});
} catch {
return null;
}
}
export async function overviewPortfolios() { export async function overviewPortfolios() {
return { return {
actief: activeName(), actief: activeName(),
installatie: installDir(), installatie: installDir(),
portefeuilles: listPortfolios(), portefeuilles: listPortfolios().map(p => ({ ...p, kerncijfers: p.heeftDatabase ? keyFigures(p.naam) : null })),
archief: await listArchived(), archief: await listArchived(),
}; };
} }
+5 -2
View File
@@ -1,8 +1,9 @@
// Generated, do not edit. sha256:82917722010d150a76bf3e84051a5132d98df9cd1713d95fe6238971925d39e8 // Generated, do not edit. sha256:ac3a8e4322658b62e93903e91a12ebe573e29b3520917e4d09d4d77210b67abb
import { mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs'; import { mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, statSync, rmSync } from 'node:fs';
import { join, resolve, dirname, sep } from 'node:path'; import { join, resolve, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { fail } from '../shared/app-error.js'; import { fail } from '../shared/app-error.js';
import { CONFIG } from '../shared/config.js';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -124,6 +125,7 @@ export function listPortfolios() {
return { return {
naam, naam,
actief: actief !== null && sameName(naam, actief), actief: actief !== null && sameName(naam, actief),
demo: sameName(naam, CONFIG.PORTFOLIO.DEMO_NAME),
heeftDatabase: bestaat(db), heeftDatabase: bestaat(db),
heeftGeheimen: bestaat(join(map, 'secrets.json')), heeftGeheimen: bestaat(join(map, 'secrets.json')),
laatstGebruikt: mtimeOf(db), laatstGebruikt: mtimeOf(db),
@@ -194,6 +196,7 @@ export function prepareLayout() {
hernoemOudeWijzer(); hernoemOudeWijzer();
const migrated = migrateLooseLayout(); const migrated = migrateLooseLayout();
let naam = readActiveName(); let naam = readActiveName();
const fresh = !naam && !migrated && listPortfolios().length === 0;
let herkomst = `uit ${POINTER_NAME}`; let herkomst = `uit ${POINTER_NAME}`;
if (!naam) { if (!naam) {
naam = writeActiveName(migrated || DEFAULT_NAME); naam = writeActiveName(migrated || DEFAULT_NAME);
@@ -202,5 +205,5 @@ export function prepareLayout() {
herkomst = `uit ${POINTER_NAME}; losse bestanden verhuisd naar '${migrated}'`; herkomst = `uit ${POINTER_NAME}; losse bestanden verhuisd naar '${migrated}'`;
} }
mkdirSync(portfolioDir(naam), { recursive: true }); mkdirSync(portfolioDir(naam), { recursive: true });
return { name: naam, migrated, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` }; return { name: naam, migrated, fresh, regel: `portefeuille: ${naam} (${herkomst}) | map: ${portfolioDir(naam)}` };
} }
+6 -3
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:79db5d8cc5706ad987b207d98e1c67ca389284a24121f65b199acbe48bce76ec // Generated, do not edit. sha256:1710b5a1008b7f73cde7525daaf3570e3ed0738fc95514486b369ae15598fb4b
const GRAMS_PER_TROY_OUNCE = 31.1034768; const GRAMS_PER_TROY_OUNCE = 31.1034768;
export const CONFIG = Object.freeze({ export const CONFIG = Object.freeze({
@@ -243,9 +243,10 @@ export const CONFIG = Object.freeze({
{ id: 'dashboard', group: 'overview', free: null }, { id: 'wealth', group: 'overview', free: null }, { id: 'dashboard', group: 'overview', free: null }, { id: 'wealth', group: 'overview', free: null },
{ id: 'returns', group: 'overview', free: null }, { id: 'taxes', group: 'overview', has: 'tax' }, { id: 'returns', group: 'overview', free: null }, { id: 'taxes', group: 'overview', has: 'tax' },
{ id: 'balances', group: 'records', free: null }, { id: 'interest', group: 'records', free: null }, { id: 'balances', group: 'records', free: null }, { id: 'interest', group: 'records', free: null },
{ id: 'accountMerge', group: 'records', has: 'accountMerge' },
{ id: 'import', group: 'records', has: 'import' }, { id: 'data', group: 'records', has: 'data' }, { id: 'import', group: 'records', has: 'import' }, { id: 'data', group: 'records', has: 'data' },
{ id: 'health', group: 'records', has: 'health' }, { id: 'metals', group: 'records', free: false }, { id: 'health', group: 'records', has: 'health' }, { id: 'metals', group: 'records', free: false },
{ id: 'priceHistory', group: 'prices', free: null }, { id: 'priceHistory', group: 'prices', has: 'priceHistory' },
{ id: 'sourceChoice', group: 'prices', has: 'sourceMatrix' }, { id: 'providerKeys', group: 'prices', has: 'providerKeys' }, { id: 'sourceChoice', group: 'prices', has: 'sourceMatrix' }, { id: 'providerKeys', group: 'prices', has: 'providerKeys' },
{ id: 'portfolios', group: 'app', has: 'portfolios' }, { id: 'ai', group: 'app', has: 'ai' }, { id: 'portfolios', group: 'app', has: 'portfolios' }, { id: 'ai', group: 'app', has: 'ai' },
{ id: 'kiosk', group: 'app', has: 'kiosk' }, { id: 'kiosk', group: 'app', has: 'kiosk' },
@@ -266,7 +267,6 @@ export const CONFIG = Object.freeze({
assets: 1, assets: 1,
fiatAccounts: 1, fiatAccounts: 1,
portfolios: 1, portfolios: 1,
valuations: 1,
}), }),
ASSETS: Object.freeze(['BTC']), ASSETS: Object.freeze(['BTC']),
HAS: Object.freeze({ HAS: Object.freeze({
@@ -283,6 +283,8 @@ export const CONFIG = Object.freeze({
data: false, data: false,
health: false, health: false,
kiosk: false, kiosk: false,
accountMerge: false,
priceHistory: false,
}), }),
}), }),
}), }),
@@ -300,6 +302,7 @@ export const CONFIG = Object.freeze({
PORTFOLIO: Object.freeze({ PORTFOLIO: Object.freeze({
RESTART_EXIT_CODE: 75, RESTART_EXIT_CODE: 75,
DEMO_NAME: 'Demo',
DEFAULT_BASE_CURRENCY: 'EUR', DEFAULT_BASE_CURRENCY: 'EUR',
BASE_CURRENCIES: Object.freeze([ BASE_CURRENCIES: Object.freeze([
'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'IDR', 'ILS', 'INR', 'ISK',
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e2490264df449b0893dfa0c9153c9a28823fc105b7143f0c7281166235765818 // Generated, do not edit. sha256:33299173b137f702925900408d288146bebcc5895b1c84a7d6e2f52c4663aa64
export const en = { export const en = {
places: { places: {
HOME: 'The dashboard. You build the layout yourself from widgets (add, drag, adjust width, restore the default layout) and see balances, prices and signals at a glance.', HOME: 'The dashboard. You build the layout yourself from widgets (add, drag, adjust width, restore the default layout) and see balances, prices and signals at a glance.',
@@ -383,7 +383,7 @@ export const en = {
license: { license: {
title: 'Free and full version', title: 'Free and full version',
body: [ body: [
'The free version has no time limit, with small records: one asset, one fiat account, one balance and one portfolio.', 'The free version has no time limit, with small records: one asset, one fiat account and one portfolio.',
'Integrations, the source choice, your own API keys and the tax page are part of the full version. Reading and exporting always keep working; nothing is ever taken away.', 'Integrations, the source choice, your own API keys and the tax page are part of the full version. Reading and exporting always keep working; nothing is ever taken away.',
'The license file goes on {LICENSE} and takes effect immediately. Every lock elsewhere in the app points here.', 'The license file goes on {LICENSE} and takes effect immediately. Every lock elsewhere in the app points here.',
], ],
+9 -9
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:88c3787099bdacec2cc7f0323d565d172c1d53d814c3d3c14973885706e0e046 // Generated, do not edit. sha256:1da9478920ae7b61142019f1be56d25f8f2f2cbc26eb597cd15c9947103fe78a
export const nl = { export const nl = {
places: { places: {
HOME: 'Het dashboard. U stelt de indeling zelf samen uit widgets (toevoegen, verslepen, breedte aanpassen, standaardindeling herstellen) en ziet in één oogopslag saldi, koersen en signalen.', HOME: 'Het dashboard. U stelt de indeling zelf samen uit widgets (toevoegen, verslepen, breedte aanpassen, standaardindeling herstellen) en ziet in één oogopslag saldi, koersen en signalen.',
@@ -22,11 +22,11 @@ export const nl = {
RESERVATIONS: 'Reserveringen. U legt per account een bedrag vast dat later mogelijk wordt afgeschreven of bijgeboekt; het telt nergens mee tot u het boekt of zonder boeking afhandelt.', RESERVATIONS: 'Reserveringen. U legt per account een bedrag vast dat later mogelijk wordt afgeschreven of bijgeboekt; het telt nergens mee tot u het boekt of zonder boeking afhandelt.',
ACCOUNTS: 'Accounts. U maakt een account aan, sluit het af of heropent het, zet automatisch scannen aan, en koppelt er wallet-bronnen aan (een xpub of los adres). Ook samenvoegen kan hier.', ACCOUNTS: 'Accounts. U maakt een account aan, sluit het af of heropent het, zet automatisch scannen aan, en koppelt er wallet-bronnen aan (een xpub of los adres). Ook samenvoegen kan hier.',
ASSETS: 'Assets. U voegt een asset toe of wijzigt hem (symbool, eenheid, ondereenheid, precisie, weergave), en beheert de metaalproducten die eronder hangen.', ASSETS: 'Assets. U voegt een asset toe of wijzigt hem (symbool, eenheid, ondereenheid, precisie, weergave), en beheert de metaalproducten die eronder hangen.',
PORTFOLIOS: 'Portefeuilles. Elke portefeuille is een eigen administratie, met een eigen database en eigen geheimen; er is er altijd precies één open. U maakt er een aan (leeg of als kopie van een bestaande), schakelt over naar een andere (de app start daarvoor opnieuw), hernoemt of verwijdert er een, en stelt in wie er bij deze portefeuille mag. Verwijderen verplaatst naar het archief; daar kan het definitief weg. Hier staan ook de handelingen op de administratie als bestand: een back-up maken, de database exporteren of terugzetten, en leeg beginnen.', PORTFOLIOS: 'Portfolio\'s. Elk portfolio is een eigen administratie, met een eigen database en eigen geheimen; er is er altijd precies één open. U maakt er een aan (leeg of als kopie van een bestaand), schakelt over naar een ander (de app start daarvoor opnieuw), hernoemt of verwijdert er een, en stelt in wie er bij dit portfolio mag. Verwijderen verplaatst naar het archief; daar kan het definitief weg. Hier staan ook de handelingen op de administratie als bestand: een back-up maken, de database exporteren of terugzetten, en leeg beginnen.',
SETTINGS: 'Instellingen. Thema en accentkleur, valuta, taal, de privacy-blur, welke naam een asset in tabellen krijgt, en de intervallen van de achtergrondtaken.', SETTINGS: 'Instellingen. Thema en accentkleur, valuta, taal, de privacy-blur, welke naam een asset in tabellen krijgt, en de intervallen van de achtergrondtaken.',
LICENSE: 'Licentie. Welke versie deze installatie draait, voor wie de licentie is en voor welke hoofdversies, en waar u een licentiebestand neerzet. Elk slot elders in de app verwijst hierheen.', LICENSE: 'Licentie. Welke versie deze installatie draait, voor wie de licentie is en voor welke hoofdversies, en waar u een licentiebestand neerzet. Elk slot elders in de app verwijst hierheen.',
IMPORT: 'Importeren. U leest gebeurtenissen in uit een CSV-bestand, koppelt de kolommen aan de velden en ziet vóór het opslaan wat er zou worden toegevoegd.', IMPORT: 'Importeren. U leest gebeurtenissen in uit een CSV-bestand, koppelt de kolommen aan de velden en ziet vóór het opslaan wat er zou worden toegevoegd.',
DATA: 'Data. Een read-only kijkje in de rauwe inhoud van de tabellen. Bedoeld om te controleren, niet om te wijzigen; de back-up staat bij Portefeuilles.', DATA: 'Data. Een read-only kijkje in de rauwe inhoud van de tabellen. Bedoeld om te controleren, niet om te wijzigen; de back-up staat bij Portfolio\'s.',
HEALTH: 'Gezondheid. Tien controles op de administratie zelf, met per bevinding wat er aan de hand is: dubbele boekingen, balansen die niet verzoend zijn, achterstand en gaten in de koershistorie, verplaatsingen zonder tegenkant. U leest hier, u handelt af op Af te handelen.', HEALTH: 'Gezondheid. Tien controles op de administratie zelf, met per bevinding wat er aan de hand is: dubbele boekingen, balansen die niet verzoend zijn, achterstand en gaten in de koershistorie, verplaatsingen zonder tegenkant. U leest hier, u handelt af op Af te handelen.',
AI_ACTIVITY: 'AI-activiteit. U zet de AI-koppeling en het navigatierecht aan of uit, leest per aanroep na wat er gebeurde en welke velden de app verlieten, en bekijkt vooraf wat een handeling zou delen.', AI_ACTIVITY: 'AI-activiteit. U zet de AI-koppeling en het navigatierecht aan of uit, leest per aanroep na wat er gebeurde en welke velden de app verlieten, en bekijkt vooraf wat een handeling zou delen.',
INTEGRATIONS: 'Integraties. Het beheer van alles wat buiten de app zit: koersbronnen, hun sleutels, de verbindingen met een explorer of node, en voorstellen voor nieuwe bronnen.', INTEGRATIONS: 'Integraties. Het beheer van alles wat buiten de app zit: koersbronnen, hun sleutels, de verbindingen met een explorer of node, en voorstellen voor nieuwe bronnen.',
@@ -275,9 +275,9 @@ export const nl = {
['Navigatie', 'Het scherm naar een andere pagina brengen, na een aftelling met een stopknop.'], ['Navigatie', 'Het scherm naar een andere pagina brengen, na een aftelling met een stopknop.'],
['Onbewerkt delen', 'Uit ziet de AI verwijzingen en marges in plaats van namen en bedragen.'], ['Onbewerkt delen', 'Uit ziet de AI verwijzingen en marges in plaats van namen en bedragen.'],
] }, ] },
{ note: 'Onbewerkt delen alleen aanzetten in een portefeuille zonder echte gegevens.' }, { note: 'Onbewerkt delen alleen aanzetten in een portfolio zonder echte gegevens.' },
'Daaronder bepalen vier schuiven hoe precies de AI de cijfers ziet: percentages, totalen, bedragen en hoeveelheden, als marge of exact.', 'Daaronder bepalen vier schuiven hoe precies de AI de cijfers ziet: percentages, totalen, bedragen en hoeveelheden, als marge of exact.',
'Elke aanroep staat op {AI_ACTIVITY}, met de gegevens die daarbij de app verlieten. De instellingen gelden per portefeuille en meteen, zonder herstart.', 'Elke aanroep staat op {AI_ACTIVITY}, met de gegevens die daarbij de app verlieten. De instellingen gelden per portfolio en meteen, zonder herstart.',
], ],
}, },
interest: { interest: {
@@ -383,19 +383,19 @@ export const nl = {
license: { license: {
title: 'Gratis en volledige versie', title: 'Gratis en volledige versie',
body: [ body: [
'De gratis versie is onbeperkt in tijd, met een kleine administratie: één asset, één fiatrekening, één balans en één portefeuille.', 'De gratis versie is onbeperkt in tijd, met een kleine administratie: één asset, één fiatrekening en één portfolio.',
'Koppelingen, de bronkeuze, eigen sleutels en de belastingpagina horen bij de volledige versie. Lezen en exporteren blijven altijd werken; er wordt nooit iets weggehaald.', 'Koppelingen, de bronkeuze, eigen sleutels en de belastingpagina horen bij de volledige versie. Lezen en exporteren blijven altijd werken; er wordt nooit iets weggehaald.',
'Het licentiebestand gaat op {LICENSE} en geldt meteen. Elk slot elders in de app verwijst hierheen.', 'Het licentiebestand gaat op {LICENSE} en geldt meteen. Elk slot elders in de app verwijst hierheen.',
], ],
}, },
portfolios: { portfolios: {
title: 'Portefeuilles', title: "Portfolio's",
body: [ body: [
'Elke portefeuille is een aparte administratie, met een eigen database en eigen geheimen. Er is er altijd één open.', 'Elk portfolio is een aparte administratie, met een eigen database en eigen geheimen. Er is er altijd één open.',
{ list: [ { list: [
['Rekenvaluta', 'Gekozen bij het aanmaken en daarna vast. Alle bedragen staan in die munt.'], ['Rekenvaluta', 'Gekozen bij het aanmaken en daarna vast. Alle bedragen staan in die munt.'],
['Wisselen', 'De app start daarvoor opnieuw.'], ['Wisselen', 'De app start daarvoor opnieuw.'],
['Verwijderen', 'Verplaatst de portefeuille naar het archief. Pas daar kan hij definitief weg.'], ['Verwijderen', 'Verplaatst het portfolio naar het archief. Pas daar kan het definitief weg.'],
] }, ] },
{ note: 'Een back-up bevat database en geheimen samen: alleen samen kloppen ze.' }, { note: 'Een back-up bevat database en geheimen samen: alleen samen kloppen ze.' },
], ],
+7 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:3914f1ddc9004d83b8401e7c0b8dbbc55835029676e9b46f33978bfb59e5b5ee // Generated, do not edit. sha256:b4e0276b7d4bc8c4728c77282dde12358131e2103dd3182aa9dbf27cc334764b
import { describe, it, beforeEach, afterEach } from 'node:test'; import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -261,6 +261,12 @@ describe('seedPreferences', () => {
assert.equal(PREFERENCES.app.market_spot.initial, 'off', 'de terugval van een bestaande portefeuille'); assert.equal(PREFERENCES.app.market_spot.initial, 'off', 'de terugval van een bestaande portefeuille');
}); });
it('zet de AI-koppeling uit in een verse database, en laat de terugval aan', () => {
seedPreferences();
assert.equal(waarde('ai', 'enabled'), 0);
assert.equal(PREFERENCES.ai.enabled.initial, 1, 'de terugval van een bestaande portefeuille');
});
it('laat de andere voorkeuren op hun initial', () => { it('laat de andere voorkeuren op hun initial', () => {
seedPreferences(); seedPreferences();
assert.equal(waarde('app', 'result_signs'), PREFERENCES.app.result_signs.initial); assert.equal(waarde('app', 'result_signs'), PREFERENCES.app.result_signs.initial);
+51
View File
@@ -0,0 +1,51 @@
// Generated, do not edit. sha256:4bf1820de70357b7a7e29ccee6fc599f970ee70dfc5b2f00ce791c38d33bc92e
import { describe, it, mock, afterEach } from 'node:test';
import assert from 'node:assert/strict';
const { fetchJson } = await import('../../client/price/btc-explorer.js');
const echteFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = echteFetch; mock.timers.reset(); });
async function uitzitten(belofte) {
let klaar = false;
const uit = belofte.then(v => ({ v }), e => ({ e })).finally(() => { klaar = true; });
while (!klaar) {
await new Promise(r => setImmediate(r));
mock.timers.runAll();
}
return uit;
}
const ok = body => ({ ok: true, status: 200, json: async () => body });
const status = s => ({ ok: false, status: s, json: async () => ({}) });
describe('fetchJson: opnieuw proberen', () => {
it('zit drie netwerkfouten uit en geeft dan het antwoord', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let n = 0;
globalThis.fetch = async () => { if (++n <= 3) throw new TypeError('Failed to fetch'); return ok({ x: 1 }); };
const { v, e } = await uitzitten(fetchJson('https://x/api', 'X'));
assert.equal(e, undefined);
assert.deepEqual(v, { x: 1 });
assert.equal(n, 4);
});
it('geeft na de vierde mislukte poging de fout van de laatste', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let n = 0;
globalThis.fetch = async () => { n++; return status(429); };
const { e } = await uitzitten(fetchJson('https://x/api', 'X'));
assert.ok(e);
assert.equal(n, 4);
});
it('probeert een 404 niet opnieuw', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
let n = 0;
globalThis.fetch = async () => { n++; return status(404); };
const { e } = await uitzitten(fetchJson('https://x/api', 'X'));
assert.ok(e);
assert.equal(n, 1);
});
});
+16 -17
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:38c4b07d1566cda74ef6cac4d031f49fd52004b6938fdee8f13e3e7678fc12c7 // Generated, do not edit. sha256:13b6b32cbbf6b8c4a6a6069af37a7be33560a8abfc2af81ae2b4009e9692d7c6
import { describe, it, beforeEach, mock } from 'node:test'; import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs'; import { createDbHarness } from '../helpers/db-node.mjs';
@@ -22,6 +22,7 @@ mock.module('../../server/license.mjs', {
const { createAsset, updateAsset, deleteAsset, listAssets } = await import('../../server/data/assets.js'); const { createAsset, updateAsset, deleteAsset, listAssets } = await import('../../server/data/assets.js');
const { createAccount, listAccounts } = await import('../../server/data/accounts.js'); const { createAccount, listAccounts } = await import('../../server/data/accounts.js');
const { mergeAccounts } = await import('../../server/data/accounts-merge.js');
const { createValuation, listValuations } = await import('../../server/data/valuations.js'); const { createValuation, listValuations } = await import('../../server/data/valuations.js');
const { saveConnection, listConnections } = await import('../../server/data/connections.js'); const { saveConnection, listConnections } = await import('../../server/data/connections.js');
const { setPriceSourceRank } = await import('../../server/data/price-source.js'); const { setPriceSourceRank } = await import('../../server/data/price-source.js');
@@ -148,23 +149,10 @@ describe('de gratis stand op balansen', () => {
rateAt: `${dag}T00:00:00Z`, source: 'manual', rateAt: `${dag}T00:00:00Z`, source: 'manual',
}); });
it('laat ÉÉN balans toe en weigert de tweede', async () => { it('kent geen grens: ook de gratis stand legt balans na balans vast', async () => {
const asset = await createAsset(EEN); const asset = await createAsset(EEN);
await createValuation(balans(asset, '2026-01-01')); for (const dag of ['2026-01-01', '2026-02-01', '2026-03-01']) await createValuation(balans(asset, dag));
await assert.rejects(() => createValuation(balans(asset, '2026-02-01')), (fout) => { assert.equal(listValuations().length, 3);
assert.equal(fout.key, 'valuations.maxFree');
assert.equal(fout.params.max, 1);
return true;
});
assert.equal(listValuations().length, 1);
});
it('laat de tweede WEL toe in de betaalde stand', async () => {
const asset = await createAsset(EEN);
await createValuation(balans(asset, '2026-01-01'));
stand = { level: 'full', enforced: true, why: 'licensed' };
await createValuation(balans(asset, '2026-02-01'));
assert.equal(listValuations().length, 2);
}); });
}); });
@@ -212,4 +200,15 @@ describe('de features van de gratis stand', () => {
await assert.rejects(() => setPriceSourceRank(asset, 'spot', 1), await assert.rejects(() => setPriceSourceRank(asset, 'spot', 1),
(f) => f.key === 'integrations.matrixPaid'); (f) => f.key === 'integrations.matrixPaid');
}); });
it('weigert accounts samenvoegen en laat beide accounts staan; betaald gaat het door', async () => {
const wallet = (name) => ({ name, kind: 'wallet', isOwnCustody: true, scanEnabled: false, taxCategory: null });
const a = await createAccount(wallet('A'));
const b = await createAccount(wallet('B'));
await assert.rejects(() => mergeAccounts(a, b), (f) => f.key === 'accounts.mergePaid');
assert.equal(listAccounts().length, 2);
stand = { level: 'full', enforced: true, why: 'licensed' };
await mergeAccounts(a, b);
assert.deepEqual(listAccounts().map(r => r.id), [a]);
});
}); });
-302
View File
@@ -1,302 +0,0 @@
// Generated, do not edit. sha256:ba6482c6f8221f445c935cfd65e76c09e943da662ff5dab04e50d8a76e004316
import { describe, it, beforeEach, mock } from 'node:test';
import { toSearchName } from '../../shared/search-name.js';
import assert from 'node:assert/strict';
import { createDbHarness } from '../helpers/db-node.mjs';
import { ACCOUNT_SOURCE_KINDS } from '../../shared/config.js';
const harness = createDbHarness();
mock.module('../../server/db.js', { namedExports: harness.exports });
const { mergeAccounts, mergeCheck } = await import('../../server/data/accounts-merge.js');
const { eventsNetByAccount } = await import('../../server/data/events.js');
let btc;
beforeEach(() => {
harness.recreate();
harness.exports.run(
`INSERT INTO Asset (symbol, name, searchName, kind, unit, subunit, decimals, displayUnit) VALUES ('BTC','Bitcoin','bitcoin','crypto','BTC','sat',8,'unit')`);
btc = harness.exports.lastId();
});
function account(naam, kind = 'wallet') {
const eigen = ACCOUNT_SOURCE_KINDS[kind].length > 0 ? 1 : 0;
harness.exports.run(
`INSERT INTO Account (name, searchName, kind, isOwnCustody, scanEnabled, isActive, closedAt) VALUES (?,?,?,?,?,1,NULL)`, [naam, toSearchName(naam), kind, eigen, 0]);
return harness.exports.lastId();
}
function event({ type = 'buy', from = null, to = null, amount = 1000, at = '2024-01-01T00:00:00', unitFee = null }) {
harness.exports.run(
`INSERT INTO Event (assetId, type, occurredAt, amount, fiatPrice, unitFee, fromAccountId, toAccountId, source, createdAt)
VALUES (?,?,?,?,?,?,?,?,'manual',?)`,
[btc, type, at, amount, type === 'buy' || type === 'sell' ? 1 : null, unitFee, from, to, at]);
return harness.exports.lastId();
}
function waardering({ acc, onDate, balance, rateAt = null }) {
harness.exports.run(
`INSERT INTO Valuation (assetId, valuedOn, accountId, balance, fiatRate, rateAt, source)
VALUES (?,?,?,?,?,?, 'manual')`,
[btc, onDate, acc, balance, 50000, rateAt ?? `${onDate}T00:00:00`]);
return harness.exports.lastId();
}
const tel = (tabel, kolom, id) =>
harness.exports.one(`SELECT COUNT(*) AS n FROM ${tabel} WHERE ${kolom} = ?`, [id]).n;
const bestaatAccount = id => harness.exports.one(`SELECT 1 AS x FROM Account WHERE id=?`, [id]) != null;
describe('mergeCheck: compatibiliteit', () => {
it('vraagt om twee accounts', () => {
const a = account('A');
assert.equal(mergeCheck(a, null).reason, 'pickTwo');
assert.equal(mergeCheck(null, a).reason, 'pickTwo');
assert.equal(mergeCheck(a, null).counts, null);
});
it('weigert een account met zichzelf', () => {
const a = account('A');
assert.equal(mergeCheck(a, a).reason, 'sameAccount');
assert.equal(mergeCheck(String(a), a).reason, 'sameAccount',
'ook als het id als string binnenkomt (uit een <select>)');
});
it('weigert een onbekend account', () => {
const a = account('A');
assert.equal(mergeCheck(a, 9999).reason, 'notFound');
});
it('weigert custodial × trackable', () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
assert.equal(mergeCheck(beurs, wallet).reason, 'sourceMismatch');
assert.equal(mergeCheck(wallet, beurs).reason, 'sourceMismatch',
'de regel geldt in beide richtingen');
});
it('weigert een overlever die de bron-soorten van de opheffer niet kan dragen', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(wallet, node).reason, 'kindCannotCarry');
});
it('staat de andere richting wél toe', () => {
const wallet = account('Wallet', 'wallet');
const node = account('Node', 'lightning');
assert.equal(mergeCheck(node, wallet).ok, true,
'lightning kan alles dragen wat wallet kan');
});
it('staat twee custodial soorten toe', () => {
const bank = account('Bank', 'bank');
const kluis = account('Kluis', 'vault');
assert.equal(mergeCheck(bank, kluis).ok, true);
});
it('telt wat er zou verhuizen, en kijkt naar beide event-kanten', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
event({ type: 'buy', to: s });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qtest', '2024-01-01T00:00:00')`, [v]);
waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
const chk = mergeCheck(s, v);
assert.equal(chk.ok, true);
assert.deepEqual(chk.counts, { sources: 1, events: 2, schedules: 0, valuations: 1 });
assert.equal(chk.survivorName, 'S');
assert.equal(chk.victimName, 'V');
});
it('verandert niets aan de database', () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v });
mergeCheck(s, v);
assert.equal(tel('Event', 'toAccountId', v), 1, 'een preview hoort niets te muteren');
assert.ok(bestaatAccount(v));
});
});
describe('mergeAccounts: verhuizing van kinddata', () => {
it('weigert een incompatibele merge en laat alles staan', async () => {
const beurs = account('Beurs', 'exchange');
const wallet = account('Wallet', 'wallet');
event({ type: 'buy', to: wallet });
await assert.rejects(() => mergeAccounts(beurs, wallet), (e) => e.key === 'accounts.mergeReason_sourceMismatch');
assert.ok(bestaatAccount(wallet), 'de opheffer bestaat nog');
assert.equal(tel('Event', 'toAccountId', wallet), 1);
});
it('verhuist alle acht soorten kinddata en verwijdert de opheffer', async () => {
const s = account('Overlever');
const v = account('Opheffer');
event({ type: 'buy', to: v });
event({ type: 'sell', from: v });
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO WalletAddress (accountId, address) VALUES (?, 'bc1qadres')`, [v]);
harness.exports.run(`INSERT INTO WalletTransaction (accountId, layer, txid, direction, valueSats, createdAt)
VALUES (?, 'onchain', 'tx1', 'in', 100, '2024-01-01T00:00:00')`, [v]);
harness.exports.run(`INSERT INTO ScanLog (accountId, scannedAt, status)
VALUES (?, '2024-01-01T00:00:00', 'success')`, [v]);
harness.exports.run(`INSERT INTO ReconcileProposal
(accountId, assetId, amount, direction, suggestedType, status, detectedAt)
VALUES (?,?, 100, 'in', 'transfer', 'pending', '2024-01-01T00:00:00')`, [v, btc]);
harness.exports.run(`INSERT INTO ImportBatch (accountId, filename, importedAt, rowCount)
VALUES (?, 'x.csv', '2024-01-01T00:00:00', 3)`, [v]);
harness.exports.run(`INSERT INTO InterestSchedule
(accountId, rateKind, payoutAssetId, startsOn, compounding, createdAt)
VALUES (?, 'variable', ?, '2024-01-01', 0, '2024-01-01T00:00:00')`, [v, btc]);
const counts = await mergeAccounts(s, v);
assert.equal(tel('Event', 'toAccountId', s), 1);
assert.equal(tel('Event', 'fromAccountId', s), 1);
assert.equal(tel('WalletSource', 'accountId', s), 1);
assert.equal(tel('WalletAddress', 'accountId', s), 1);
assert.equal(tel('WalletTransaction', 'accountId', s), 1);
assert.equal(tel('ScanLog', 'accountId', s), 1);
assert.equal(tel('ReconcileProposal', 'accountId', s), 1);
assert.equal(tel('ImportBatch', 'accountId', s), 1);
assert.equal(tel('InterestSchedule', 'accountId', s), 1);
for (const [tabel, kolom] of [['Event', 'toAccountId'], ['Event', 'fromAccountId'],
['WalletSource', 'accountId'], ['WalletAddress', 'accountId'], ['WalletTransaction', 'accountId'],
['ScanLog', 'accountId'], ['ReconcileProposal', 'accountId'], ['ImportBatch', 'accountId'],
['InterestSchedule', 'accountId']]) {
assert.equal(tel(tabel, kolom, v), 0, `${tabel}.${kolom} verwijst nog naar de opheffer`);
}
assert.equal(bestaatAccount(v), false, 'de opheffer hoort verdwenen te zijn');
assert.ok(bestaatAccount(s));
assert.equal(counts.events, 2, 'de teruggegeven counts komen uit de voorbeschouwing');
});
it("laat de bron-id's ongemoeid, zodat NWC-secrets geldig blijven", async () => {
const s = account('S');
const v = account('V');
harness.exports.run(`INSERT INTO WalletSource (accountId, kind, value, createdAt)
VALUES (?, 'address', 'bc1qbron', '2024-01-01T00:00:00')`, [v]);
const bronId = harness.exports.lastId();
await mergeAccounts(s, v);
const bron = harness.exports.one(`SELECT id, accountId FROM WalletSource`);
assert.equal(bron.id, bronId, 'het bron-id mag niet wijzigen');
assert.equal(bron.accountId, s);
});
});
describe('mergeAccounts: waarderingen', () => {
const verschil = (valId) => {
const v = harness.exports.one(`SELECT accountId, assetId, rateAt, balance FROM Valuation WHERE id=?`, [valId]);
return v.balance - eventsNetByAccount(v.accountId, v.assetId, v.rateAt);
};
it('houdt het verschil van elke waardering gelijk', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
assert.equal(verschil(valV), 200);
assert.equal(verschil(valS), 0);
await mergeAccounts(s, v);
assert.equal(verschil(valV), 200, 'een bestaand verschil moet behouden blijven');
assert.equal(verschil(valS), 0, 'reconciled (0) moet 0 blijven');
});
it('corrigeert de balance daarvoor, ook van de overlever zelf', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 500, at: '2024-01-01T00:00:00' });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
const valS = waardering({ acc: s, onDate: '2024-03-01', balance: 500 });
await mergeAccounts(s, v);
const balans = id => harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [id]).balance;
assert.equal(balans(valV), 1700, '1500 grootboek + 200 verschil');
assert.equal(balans(valS), 1500, '1500 grootboek + 0 verschil, dus herschreven van 500');
});
it('verhuist een niet-botsende waardering naar de overlever', async () => {
const s = account('S');
const v = account('V');
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 10 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT accountId FROM Valuation WHERE id=?`, [val]).accountId, s);
});
it('houdt bij een botsing precies één waardering over', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
const over = harness.exports.all(`SELECT accountId, balance FROM Valuation`);
assert.equal(over.length, 1, 'geen dubbele rij, geen UNIQUE-fout');
assert.equal(over[0].accountId, s, 'de overgebleven rij hangt aan de overlever');
});
it('wist bij een botsing de rij van de opheffer, niet die van de overlever', async () => {
const s = account('S');
const v = account('V');
const valS = waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
const valV = waardering({ acc: v, onDate: '2024-02-01', balance: 1200 });
await mergeAccounts(s, v);
assert.ok(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valS]),
'de waardering van de overlever blijft');
assert.equal(harness.exports.one(`SELECT 1 AS x FROM Valuation WHERE id=?`, [valV]), null,
'de botsende rij van de opheffer wijkt');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation`).balance, 500,
'en dus blijft de gemeten balans van het behouden account staan');
});
it('botst alleen op dezelfde datum, niet op hetzelfde asset alleen', async () => {
const s = account('S');
const v = account('V');
waardering({ acc: s, onDate: '2024-02-01', balance: 500 });
waardering({ acc: v, onDate: '2024-02-02', balance: 1200 });
await mergeAccounts(s, v);
assert.equal(harness.exports.one(`SELECT COUNT(*) AS n FROM Valuation`).n, 2);
});
it('rekent het verschil met transfers en fees mee', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'transfer', from: v, to: null, amount: 300, unitFee: 20, at: '2024-01-15T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 700 });
assert.equal(verschil(val), 20);
await mergeAccounts(s, v);
assert.equal(verschil(val), 20);
});
it('negeert waarderingen ná het rateAt-moment bij de correctie', async () => {
const s = account('S');
const v = account('V');
event({ type: 'buy', to: v, amount: 1000, at: '2024-01-01T00:00:00' });
event({ type: 'buy', to: s, amount: 9999, at: '2024-06-01T00:00:00' });
const val = waardering({ acc: v, onDate: '2024-02-01', balance: 1000 });
assert.equal(verschil(val), 0);
await mergeAccounts(s, v);
assert.equal(verschil(val), 0, 'de late aankoop hoort buiten dit venster te vallen');
assert.equal(harness.exports.one(`SELECT balance FROM Valuation WHERE id=?`, [val]).balance, 1000,
'balance blijft 1000: het grootboek t/m die datum is niet veranderd');
});
});
+10 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e7c1f8e0ef8ff3927bf8ff8a92bc2028aac07446bbf50cb1b0842db32ca8e5f3 // Generated, do not edit. sha256:500c3d5e31a736de36a900bebc7682d080b1ebb8cda99a94331c6ce11a3fab67
import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm } from 'node:fs/promises';
@@ -16,6 +16,7 @@ mock.module('../../server/license.mjs', {
}); });
const { listPortfolios } = await import('../../server/portfolios.mjs'); const { listPortfolios } = await import('../../server/portfolios.mjs');
const { CONFIG } = await import('../../shared/config.js');
const { createPortfolio, archivePortfolio } = await import('../../server/portfolios-admin.mjs'); const { createPortfolio, archivePortfolio } = await import('../../server/portfolios-admin.mjs');
let DIR; let DIR;
@@ -50,6 +51,14 @@ describe('de gratis stand op portefeuilles', () => {
assert.deepEqual(listPortfolios().map((p) => p.naam), ['Eerste']); assert.deepEqual(listPortfolios().map((p) => p.naam), ['Eerste']);
}); });
it('telt de demo niet mee: naast de demo past nog een eigen portefeuille', async () => {
stand = { level: 'full', enforced: true, why: 'licensed' };
await createPortfolio(CONFIG.PORTFOLIO.DEMO_NAME);
stand = { level: 'free', enforced: true, why: 'none' };
await createPortfolio('Eigen');
await assert.rejects(() => createPortfolio('Tweede'), (fout) => fout.key === 'portfolios.maxFree');
});
it('laat de tweede WEL toe in de betaalde stand', async () => { it('laat de tweede WEL toe in de betaalde stand', async () => {
await createPortfolio('Eerste'); await createPortfolio('Eerste');
stand = { level: 'full', enforced: true, why: 'licensed' }; stand = { level: 'full', enforced: true, why: 'licensed' };
+8 -5
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:4b1eef4e0860bce440fbf0875965c32cd7b13b237d0ffc92ef0255b11fe9a950 // Generated, do not edit. sha256:687aaa4f407ca89e2e9f7cb5d0e2f1e02a1d00b4fdf4b9f0890c187e9171588f
import { describe, it, before, after } from 'node:test'; import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
@@ -30,10 +30,13 @@ const wijsNaar = (naam) => writeFile(join(dir, 'active.json'), JSON.stringify({
before(async () => { before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-wissel-')); dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-wissel-'));
proc = spawn(process.execPath, [STDIO], { const env = { ...process.env, ASSETAPP_DATA_DIR: dir };
env: { ...process.env, ASSETAPP_DATA_DIR: dir }, const eerste = spawn(process.execPath, [STDIO], { env, stdio: 'ignore' });
stdio: ['pipe', 'pipe', 'pipe'], await new Promise((klaar) => eerste.once('exit', klaar));
}); const db = new DatabaseSync(join(dir, 'MyAssets', 'db.sqlite'));
db.exec(`UPDATE Preference SET value = '1' WHERE scope = 'ai' AND key = 'enabled'`);
db.close();
proc = spawn(process.execPath, [STDIO], { env, stdio: ['pipe', 'pipe', 'pipe'] });
proc.stdout.on('data', (brok) => { proc.stdout.on('data', (brok) => {
buffer += brok.toString(); buffer += brok.toString();
let n; let n;
+11 -7
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:7bb4b4b51bf14bf2fa32107d316282a41f1c7586c33fc94c43098199bfa75953 // Generated, do not edit. sha256:d705db22a94bcceb849648fab7596bd5a84e34d8b58a472ec9dc677c8db0cc0a
import { describe, it, before, after } from 'node:test'; import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
const WORTEL = join(fileURLToPath(import.meta.url), '..', '..', '..'); const WORTEL = join(fileURLToPath(import.meta.url), '..', '..', '..');
const STDIO = join(WORTEL, 'server', 'mcp', 'stdio.mjs'); const STDIO = join(WORTEL, 'server', 'mcp', 'stdio.mjs');
let dir, proc, buffer = '', wachtenden = []; let dir, dbPad, proc, buffer = '', wachtenden = [];
function stuur(bericht) { function stuur(bericht) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -24,10 +24,14 @@ function stuur(bericht) {
before(async () => { before(async () => {
dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-stdio-')); dir = await mkdtemp(join(tmpdir(), 'assetapp-mcp-stdio-'));
proc = spawn(process.execPath, [STDIO], { dbPad = join(dir, 'MyAssets', 'db.sqlite');
env: { ...process.env, ASSETAPP_DATA_DIR: dir, ASSETAPP_DB_FILE: join(dir, 'db.sqlite') }, const env = { ...process.env, ASSETAPP_DATA_DIR: dir, ASSETAPP_DB_FILE: dbPad };
stdio: ['pipe', 'pipe', 'pipe'], const eerste = spawn(process.execPath, [STDIO], { env, stdio: 'ignore' });
}); assert.equal(await new Promise((klaar) => eerste.once('exit', klaar)), 0, 'een verse installatie stopt met de koppeling uit');
const db = new DatabaseSync(dbPad);
db.exec(`UPDATE Preference SET value = '1' WHERE scope = 'ai' AND key = 'enabled'`);
db.close();
proc = spawn(process.execPath, [STDIO], { env, stdio: ['pipe', 'pipe', 'pipe'] });
proc.stdout.on('data', (brok) => { proc.stdout.on('data', (brok) => {
buffer += brok.toString(); buffer += brok.toString();
let n; let n;
@@ -74,7 +78,7 @@ describe('mcp-stdio: het proces beantwoordt bericht na bericht', () => {
describe('mcp-stdio: de schuif "Actief" zet dit proces uit', () => { describe('mcp-stdio: de schuif "Actief" zet dit proces uit', () => {
it('stopt bij het eerstvolgende bericht zodra de schuif uit gaat', async () => { it('stopt bij het eerstvolgende bericht zodra de schuif uit gaat', async () => {
const db = new DatabaseSync(join(dir, 'db.sqlite')); const db = new DatabaseSync(dbPad);
db.exec(`PRAGMA busy_timeout = 5000`); db.exec(`PRAGMA busy_timeout = 5000`);
db.exec(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'enabled', '0') db.exec(`INSERT INTO Preference (scope, key, value) VALUES ('ai', 'enabled', '0')
ON CONFLICT (scope, key) DO UPDATE SET value = '0'`); ON CONFLICT (scope, key) DO UPDATE SET value = '0'`);
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:e9def29bc2006594fdeb8199e56fcff107612198b70d50be3a6d86402660dd84 // Generated, do not edit. sha256:27b79f8183ca4a0e1556b615e64322487b0abdd54fa1269b5d1f2751a7c279af
import { describe, it, beforeEach, mock } from 'node:test'; import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -15,7 +15,7 @@ mock.module('../../client/data/ai-audit.js', { namedExports: { countAiToolCallsS
mock.module('../../client/data/preferences.js', { namedExports: { getPref: async () => null } }); mock.module('../../client/data/preferences.js', { namedExports: { getPref: async () => null } });
mock.module('../../client/data/price-history.js', { namedExports: { listPriceGaps: async () => ({ rows: [], total: 0 }) } }); mock.module('../../client/data/price-history.js', { namedExports: { listPriceGaps: async () => ({ rows: [], total: 0 }) } });
mock.module('../../client/data/api-metrics.js', { namedExports: { apiCallsByProvider: leeg } }); mock.module('../../client/data/api-metrics.js', { namedExports: { apiCallsByProvider: leeg } });
mock.module('../../client/license.js', { namedExports: { licenseChangedSinceSeen: async () => false } }); mock.module('../../client/license.js', { namedExports: { licenseChangedSinceSeen: async () => false, locked: () => false } });
mock.module('../../client/app-settings.js', { namedExports: { appSetting: () => null, baseCurrency: () => 'EUR' } }); mock.module('../../client/app-settings.js', { namedExports: { appSetting: () => null, baseCurrency: () => 'EUR' } });
mock.module('../../client/price/providers.js', { namedExports: { rateSourceFor: async () => null } }); mock.module('../../client/price/providers.js', { namedExports: { rateSourceFor: async () => null } });
+12 -7
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:0c8f95dec7394e0dad71a6a790d7768a2d482ac976a2a68218282ae506402032 // Generated, do not edit. sha256:ac1fa206a688dd1bdc09c884947e72f6ac770db7b7ba6d03716b15de2b2b3551
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -63,10 +63,15 @@ describe('activityItems', () => {
describe('heartbeat', () => { describe('heartbeat', () => {
const item = (s, dir) => ({ x: dag(s), dir }); const item = (s, dir) => ({ x: dag(s), dir });
it('loopt per dag van de dag vóór het eerste item tot vandaag, op nul waar niets gebeurde', () => { it('loopt per dag van de dag vóór het eerste item tot de dag na vandaag, op nul waar niets gebeurde', () => {
const { points } = heartbeat([item('2026-03-02', 1)], '2026-03-05T12:00:00Z'); const { points } = heartbeat([item('2026-03-02', 1)], '2026-03-05T12:00:00Z');
assert.deepEqual(points.map(p => p[0]), [1, 2, 3, 4, 5].map(d => dag(`2026-03-0${d}`))); assert.deepEqual(points.map(p => p[0]), [1, 2, 3, 4, 5, 6].map(d => dag(`2026-03-0${d}`)));
assert.deepEqual(points.map(p => p[1]), [0, 1, 0, 0, 0]); assert.deepEqual(points.map(p => p[1]), [0, 1, 0, 0, 0, 0]);
});
it('zet een streepje van vandaag niet op de rand, waar de stip half wegviel', () => {
const { points } = heartbeat([item('2026-03-05', 0)], '2026-03-05T12:00:00Z');
assert.notEqual(points[points.length - 1][0], dag('2026-03-05'), 'vandaag is niet het laatste punt');
}); });
it('wijst omhoog bij meer erin, omlaag bij meer eruit, en omhoog bij evenveel', () => { it('wijst omhoog bij meer erin, omlaag bij meer eruit, en omhoog bij evenveel', () => {
@@ -88,16 +93,16 @@ describe('heartbeat', () => {
const { points, byDay } = heartbeat([item('1994-06-01', 1), item('2026-03-02', 1)], const { points, byDay } = heartbeat([item('1994-06-01', 1), item('2026-03-02', 1)],
'2026-03-03T12:00:00Z', '2026-03-01T12:00:00Z'); '2026-03-03T12:00:00Z', '2026-03-01T12:00:00Z');
assert.equal(points[0][0], dag('2026-03-01'), 'de lijn begint op de startdag, niet op de dag ervoor'); assert.equal(points[0][0], dag('2026-03-01'), 'de lijn begint op de startdag, niet op de dag ervoor');
assert.equal(points.length, 3); assert.equal(points.length, 4);
assert.equal(byDay.has(dag('1994-06-01')), false, 'de oude boeking is er niet'); assert.equal(byDay.has(dag('1994-06-01')), false, 'de oude boeking is er niet');
assert.equal(heartbeat([item('1994-06-01', 1)], '2026-03-03T12:00:00Z', '2026-03-01T12:00:00Z'), null, assert.equal(heartbeat([item('1994-06-01', 1)], '2026-03-03T12:00:00Z', '2026-03-01T12:00:00Z'), null,
'niets ná de start: geen lijn'); 'niets ná de start: geen lijn');
}); });
it('geeft niets zonder items, en twee punten als alles vandaag gebeurde', () => { it('geeft niets zonder items, en drie punten als alles vandaag gebeurde', () => {
assert.equal(heartbeat([], '2026-03-05T12:00:00Z'), null); assert.equal(heartbeat([], '2026-03-05T12:00:00Z'), null);
const { points } = heartbeat([item('2026-03-05', -1)], '2026-03-05T12:00:00Z'); const { points } = heartbeat([item('2026-03-05', -1)], '2026-03-05T12:00:00Z');
assert.equal(points.length, 2); assert.equal(points.length, 3);
assert.equal(points[1][0] - points[0][0], DAG); assert.equal(points[1][0] - points[0][0], DAG);
}); });
}); });
+2 -2
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:45c8de8a885eb07b9f327fbd56b57d092c058152ae89dc307ade593f6035be32 // Generated, do not edit. sha256:9ad90176a0537a5dae796d00139a843d9f56aa6cd2de67fc75215c16300b5802
import { describe, it, mock } from 'node:test'; import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -241,7 +241,7 @@ describe('een balans en een portefeuille op de kaart', () => {
it('toont een portefeuille-voorstel met de naam en wat het wél en niet doet', async () => { it('toont een portefeuille-voorstel met de naam en wat het wél en niet doet', async () => {
const html = await teken([portefeuille()]); const html = await teken([portefeuille()]);
assert.match(html, /Nieuwe portefeuille/); assert.match(html, /Nieuw portfolio/);
assert.match(html, /AI-demonstratie/); assert.match(html, /AI-demonstratie/);
assert.match(html, /Leeg/); assert.match(html, /Leeg/);
assert.match(html, /wisselen|niet geopend/i, 'dat hij niet geopend wordt'); assert.match(html, /wisselen|niet geopend/i, 'dat hij niet geopend wordt');
+54
View File
@@ -0,0 +1,54 @@
// Generated, do not edit. sha256:1928a498401313a668bc3ca99074cab18fed0b4d4c803c1532d4ea964f94743c
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
import { CONFIG } from '../../shared/config.js';
const dom = installDom();
const INSTELLING = { blur: 0, auto_blur_min: CONFIG.AUTO_BLUR.NEVER_MIN };
mock.module('../../client/app-settings.js', {
namedExports: {
appSetting: k => INSTELLING[k],
setAppSetting: async (k, v) => { INSTELLING[k] = v; },
},
});
const { initPrivacyBlur } = await import('../../client/ui/blur.js');
initPrivacyBlur(dom.host());
const stand = () => dom.document.documentElement.dataset.blur;
beforeEach(() => {
INSTELLING.blur = 0;
dom.document.documentElement.dataset.blur = 'off';
for (const el of dom.document.querySelectorAll('.nav-edit-toggle, button.focus')) el.remove();
});
describe('de spatiebalk en de vervaging', () => {
it('wisselt heen en terug als niets de focus heeft, en houdt de pagina stil', () => {
const doorgelaten = dom.key(dom.document.body, ' ');
assert.equal(stand(), 'on');
assert.equal(doorgelaten, false, 'geen scrollsprong van de spatie');
dom.key(dom.document.body, ' ');
assert.equal(stand(), 'off');
});
it('laat een knop met de focus zijn eigen spatie houden', () => {
const knop = dom.document.createElement('button');
knop.className = 'focus';
dom.document.body.appendChild(knop);
assert.equal(dom.key(knop, ' '), true);
assert.equal(stand(), 'off');
});
it('doet niets in bewerkmodus of met een modifier', () => {
dom.key(dom.document.body, ' ', { shiftKey: true });
assert.equal(stand(), 'off');
const schakel = dom.document.createElement('span');
schakel.className = 'nav-edit-toggle is-on';
dom.document.body.appendChild(schakel);
dom.key(dom.document.body, ' ');
assert.equal(stand(), 'off');
});
});
-429
View File
@@ -1,429 +0,0 @@
// Generated, do not edit. sha256:43e27327deb8801c6e640676df136f116a9043c3e6d4e1f65b6469a9f90a6fa6
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
const INSTELLING = { asset_show_name: 1, asset_show_symbol: 1 };
mock.module('../../client/app-settings.js', {
namedExports: { appSetting: (k) => INSTELLING[k], baseCurrency: () => 'EUR', setAppSetting: async () => {} },
});
const { fiatAtPrice, fiatValue, fiatHistorical } = await import('../../client/ui/fiat.js');
const { assetLabel, assetLabelHtml } = await import('../../client/ui/asset-label.js');
const { RANGE_DAYS, SPOT_RANGE_DAYS, rangeSeries, intradayStart, rangeCycleButton, wireRangeCycle, rangePointLabel, rangePointSources, namedDaySources } = await import('../../client/ui/range-cycle.js');
const { serviceOrder, matrixCardHtml } = await import('../../client/ui/source-matrix.js');
describe('fiatAtPrice: van kleinste eenheid naar een bedrag', () => {
it('rekent met de decimalen van het asset', () => {
assert.match(fiatAtPrice(100000, 8, 50000), /50,00/);
});
it('gebruikt acht decimalen als er geen meegegeven zijn', () => {
assert.equal(fiatAtPrice(100000000, undefined, 7), fiatAtPrice(100000000, 8, 7));
});
it('geeft een lege string als er geen prijs bekend is', () => {
assert.equal(fiatAtPrice(100, 8, null), '');
assert.equal(fiatAtPrice(100, 8, undefined), '');
});
it('rekent nul gewoon uit in plaats van hem als onbekend te behandelen', () => {
assert.match(fiatAtPrice(0, 8, 50000), /0,00/);
});
});
describe('fiatValue en fiatHistorical: de twee wegen naar leeg', () => {
it('geeft niets voor het reken-asset', () => {
assert.equal(fiatValue(12345, { id: 1, symbol: 'EUR', decimals: 2 }, 'eur'), '');
});
it('geeft niets zonder koers in de cache, en zonder asset', () => {
assert.equal(fiatValue(100, { id: 999, symbol: 'BTC', decimals: 8 }, 'eur'), '');
assert.equal(fiatValue(100, null, 'eur'), '');
assert.equal(fiatHistorical(100, null, '2026-03-01'), '');
assert.equal(fiatHistorical(100, { id: 999, decimals: 8 }, '2026-03-01'), '');
});
});
describe('assetLabel: de twee weergaveschuiven', () => {
const btc = { name: 'Bitcoin', symbol: 'BTC' };
it('toont naam en symbool samen', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.equal(assetLabel(btc), 'Bitcoin (BTC)');
});
it('toont alleen de naam, of alleen het symbool', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.equal(assetLabel(btc), 'Bitcoin');
Object.assign(INSTELLING, { asset_show_name: 0, asset_show_symbol: 1 });
assert.equal(assetLabel(btc), 'BTC');
});
it('valt terug op wat er wél is als de gekozen helft ontbreekt', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.equal(assetLabel({ symbol: 'XAU' }), 'XAU');
Object.assign(INSTELLING, { asset_show_name: 0, asset_show_symbol: 1 });
assert.equal(assetLabel({ name: 'Goud' }), 'Goud');
});
it('leest de alias-velden van een JOIN-rij', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.equal(assetLabel({ asset_name: 'Goud', asset_symbol: 'XAU' }), 'Goud (XAU)');
});
it('geeft voorrang aan het eigen veld boven de alias, ook als dat leeg is', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.equal(assetLabel({ name: null, asset_name: 'Bitcoin', symbol: 'BTC' }), 'BTC');
});
it('geeft een lege string voor niets', () => {
assert.equal(assetLabel(null), '');
assert.equal(assetLabelHtml(null), '');
});
it('laat met `withoutSymbol` het symbool weg, want de hoeveelheid droeg het al', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
const met = assetLabelHtml({ asset_name: 'Proef zonder koers', asset_symbol: 'PRFN' });
const zonder = assetLabelHtml({ asset_name: 'Proef zonder koers', asset_symbol: 'PRFN' }, { withoutSymbol: true });
assert.match(met, /PRFN/, 'zonder de vlag blijft het gedrag ongewijzigd');
assert.match(zonder, /Proef zonder koers/);
assert.ok(!zonder.includes('PRFN'), zonder);
});
it('toont een naam die gelijk is aan het symbool één keer, ook met beide schuiven aan', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.equal(assetLabel({ name: 'XRP', symbol: 'XRP' }), 'XRP');
assert.equal(assetLabel({ name: 'Xrp', symbol: 'XRP' }), 'Xrp');
assert.equal(assetLabel({ name: 'XRP Ledger', symbol: 'XRP' }), 'XRP Ledger (XRP)');
const h = assetLabelHtml({ name: 'XRP', symbol: 'XRP' });
assert.equal(h.match(/XRP/g).length, 1, h);
assert.ok(!h.includes('t-muted-cell'), h);
});
it('valt met `withoutSymbol` terug op het symbool als er geen naam te tonen is', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
assert.match(assetLabelHtml({ asset_symbol: 'PRFN' }, { withoutSymbol: true }), /PRFN/);
Object.assign(INSTELLING, { asset_show_name: 0, asset_show_symbol: 1 });
assert.match(assetLabelHtml({ asset_name: 'Bitcoin', asset_symbol: 'BTC' }, { withoutSymbol: true }), /BTC/);
});
});
describe('assetLabelHtml: bouwt HTML en escapet zelf', () => {
it('zet naam en symbool in hun eigen span', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 1 });
const h = assetLabelHtml({ name: 'Bitcoin', symbol: 'BTC' });
assert.match(h, /<span class="asset-name">Bitcoin<\/span>/);
assert.match(h, /<span class="t-muted-cell">BTC<\/span>/);
});
it('escapet een naam uit de database', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
const h = assetLabelHtml({ name: '<script>x</script> & co' });
assert.equal(h, '<span class="asset-name">&lt;script>x&lt;/script> &amp; co</span>');
assert.equal(h.includes('<script>'), false, 'de tag mag niet als tag overleven');
});
it('escapet GEEN aanhalingsteken, en dat is een grens en geen fout', () => {
Object.assign(INSTELLING, { asset_show_name: 1, asset_show_symbol: 0 });
assert.match(assetLabelHtml({ name: 'zeg "hallo"' }), /zeg "hallo"/,
'als dit ooit verandert: controleer of de aanroepers nog steeds alleen elementinhoud zijn');
});
});
describe('range-cycle: de gedeelde tijdvakknop', () => {
it('kent drie tijdvakken met hun dagen', () => {
assert.deepEqual(RANGE_DAYS, { week: 7, month: 30, year: 365 });
});
it('cyclet week naar maand naar jaar en weer terug', () => {
const gezien = [];
const wrap = {};
let huidig = 'week';
wireRangeCycle(wrap, () => huidig, (r) => { huidig = r; gezien.push(r); });
const klik = { target: { closest: (sel) => (sel === '.range-cycle-btn' ? {} : null) } };
wrap.onclick(klik); wrap.onclick(klik); wrap.onclick(klik);
assert.deepEqual(gezien, ['month', 'year', 'week']);
});
it('doet niets bij een klik naast de knop', () => {
let geroepen = 0;
const wrap = {};
wireRangeCycle(wrap, () => 'week', () => { geroepen++; });
wrap.onclick({ target: { closest: () => null } });
assert.equal(geroepen, 0);
});
it('levert een knop met het label van het huidige tijdvak', () => {
const h = rangeCycleButton('month');
assert.match(h, /range-cycle-btn/);
assert.match(h, /aria-label="/, 'een icoonknop hoort een toegankelijke naam te hebben');
});
it('de widgets met een live spot cyclen dag, week, maand, jaar', () => {
const gezien = [];
const wrap = {};
let huidig = 'day';
wireRangeCycle(wrap, () => huidig, (r) => { huidig = r; gezien.push(r); }, SPOT_RANGE_DAYS);
const klik = { target: { closest: (sel) => (sel === '.range-cycle-btn' ? {} : null) } };
for (let i = 0; i < 4; i++) wrap.onclick(klik);
assert.deepEqual(gezien, ['week', 'month', 'year', 'day']);
});
});
describe('rangeSeries', () => {
const DAG = 86400000;
const nu = Date.parse('2026-10-09T13:00:00Z');
const middernacht = Math.floor(nu / DAG) * DAG;
const maand = Array.from({ length: 30 }, (_, i) => [middernacht - (30 - i) * DAG, 100 + i]);
it('dag: precies de slotkoers van gisteren en de spot', () => {
assert.deepEqual(rangeSeries(maand, 'day', 200, nu), [[middernacht - DAG, 129], [middernacht, 200]]);
});
it('dag zonder spot: de laatste twee slotkoersen', () => {
assert.deepEqual(rangeSeries(maand, 'day', null, nu), maand.slice(-2));
});
it('week: de dagen binnen zeven dagen, met de spot als vandaag', () => {
const uit = rangeSeries(maand, 'week', 200, nu);
assert.equal(uit.length, 7);
assert.deepEqual(uit.at(-1), [middernacht, 200]);
});
it('te weinig punten in het tijdvak: de hele reeks', () => {
const kort = [[middernacht - 40 * DAG, 90], [middernacht - 35 * DAG, 95]];
assert.equal(rangeSeries(kort, 'week', null, nu), kort);
});
const binnen = [[middernacht + 3600_000, 130], [middernacht + 7200_000, 135]];
it('dag met reeks: slotkoers aan het eind van zijn dag, de reeks, de spot op nu', () => {
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, binnen),
[[middernacht, 129], ...binnen, [nu, 200]]);
});
it('dag met reeks: punten vóór het einde van de slotdag of na nu vallen weg', () => {
const rommel = [[middernacht - 60_000, 1], ...binnen, [nu + 60_000, 2]];
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, rommel), [[middernacht, 129], ...binnen, [nu, 200]]);
});
it('dag met een lege of onbruikbare reeks: de rechte lijn van nu', () => {
const rechte = [[middernacht - DAG, 129], [middernacht, 200]];
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, []), rechte);
assert.deepEqual(rangeSeries(maand, 'day', 200, nu, [[nu + 1, 5]]), rechte);
});
it('dag met reeks maar zonder spot: geen reeks, want er is geen eindpunt', () => {
assert.deepEqual(rangeSeries(maand, 'day', null, nu, binnen), maand.slice(-2));
});
it('de reeks raakt de andere tijdvakken niet', () => {
assert.deepEqual(rangeSeries(maand, 'week', 200, nu, binnen), rangeSeries(maand, 'week', 200, nu));
});
});
describe('rangePointLabel: de hint bij een punt', () => {
const datum = { day: 'numeric', month: 'short', year: 'numeric' };
it('binnen de dag: de prijs met de lokale datum en tijd', () => {
const ms = Date.UTC(2026, 9, 16, 13, 7);
const lokaal = new Date(ms).toLocaleString('nl-NL', { ...datum, hour: '2-digit', minute: '2-digit' });
assert.equal(rangePointLabel('day', '€ 72.386', ms), `€ 72.386\n${lokaal}`);
assert.match(lokaal, /2026/);
});
it('in een ander tijdvak: de kalenderdag van het punt, zonder tijd', () => {
const middernacht = Date.UTC(2026, 9, 16);
const dag = new Date(middernacht).toLocaleDateString('nl-NL', { ...datum, timeZone: 'UTC' });
assert.match(dag, /^16 okt\.? 2026$/);
for (const range of Object.keys(RANGE_DAYS)) assert.equal(rangePointLabel(range, '€ 72.386', middernacht), `€ 72.386\n${dag}`);
});
it('ook ten westen van Greenwich, waar middernacht UTC nog de vorige dag is', () => {
const oud = process.env.TZ;
process.env.TZ = 'America/New_York';
try {
assert.match(rangePointLabel('week', '€ 1', Date.UTC(2026, 9, 16)), /16 okt\.? 2026$/);
} finally {
if (oud === undefined) delete process.env.TZ; else process.env.TZ = oud;
}
});
it('zonder moment: alleen de prijs', () => {
assert.equal(rangePointLabel('day', '€ 72.386', undefined), '€ 72.386');
assert.equal(rangePointLabel('week', '€ 72.386', NaN), '€ 72.386');
});
it('met een bron: die komt als laatste, en zonder bron blijft de hint zoals hij was', () => {
const middernacht = Date.UTC(2026, 9, 16);
const dag = new Date(middernacht).toLocaleDateString('nl-NL', { ...datum, timeZone: 'UTC' });
assert.equal(rangePointLabel('week', '€ 1', middernacht, 'CoinGecko'), `€ 1\n${dag}\nCoinGecko`);
assert.equal(rangePointLabel('week', '€ 1', middernacht, null), `€ 1\n${dag}`);
assert.equal(rangePointLabel('week', '€ 1', NaN, 'CoinGecko'), '€ 1\nCoinGecko');
});
it('zet elk deel op een eigen regel, de prijs bovenaan', () => {
const regels = rangePointLabel('week', '€ 1', Date.UTC(2026, 9, 16), 'CoinGecko').split('\n');
assert.equal(regels.length, 3);
assert.equal(regels[0], '€ 1');
});
});
describe('rangePointSources: de bron per punt', () => {
const DAG = 86400000;
const d0 = Date.UTC(2026, 9, 12);
const nu = Date.UTC(2026, 9, 16, 14, 30);
const vandaag = Date.UTC(2026, 9, 16);
const grid = [[d0, 10], [d0 + DAG, 11], [d0 + 2 * DAG, 11], [d0 + 3 * DAG, 11]];
const days = new Map([[d0, 'CoinGecko'], [d0 + DAG, 'Atreides Services']]);
const dagTekst = ms => new Date(ms).toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
it('een week: eigen dagen bij naam, doorgetrokken dagen met de dag van de koers, de spot als laatste', () => {
const use = rangeSeries(grid, 'week', 12, nu);
const bron = rangePointSources(use, { range: 'week', spot: 12, nowMs: nu, live: 'Atreides Services (Bitvavo)', days });
assert.equal(use[use.length - 1][0], vandaag);
assert.equal(bron.get(d0), 'CoinGecko');
assert.equal(bron.get(d0 + DAG), 'Atreides Services');
assert.equal(bron.get(d0 + 2 * DAG), `Atreides Services, koers van ${dagTekst(d0 + DAG)}`);
assert.equal(bron.get(vandaag), 'Atreides Services (Bitvavo)');
});
it('zonder spot is het laatste punt geen actuele koers', () => {
const use = rangeSeries(grid, 'week', null, nu);
const bron = rangePointSources(use, { range: 'week', spot: null, nowMs: nu, live: 'Bitvavo', days });
assert.equal(bron.get(d0 + 3 * DAG), `Atreides Services, koers van ${dagTekst(d0 + DAG)}`);
assert.ok(![...bron.values()].includes('Bitvavo'));
});
it('binnen de dag: een minuutpunt met een eigen bron draagt die, de rest de bron van de reeks', () => {
const intraday = [[vandaag + 3600000, 11.5], [vandaag + 7200000, 11.8]];
const use = rangeSeries(grid, 'day', 12, nu, intraday);
const intradayAt = new Map([[vandaag + 3600000, 'Atreides Services (bitvavo)']]);
const bron = rangePointSources(use, { range: 'day', spot: 12, nowMs: nu, live: 'Spot', intraday: 'Minuten', intradayAt, days });
assert.equal(bron.get(vandaag + 3600000), 'Atreides Services (bitvavo)');
assert.equal(bron.get(vandaag + 7200000), 'Minuten');
assert.equal(bron.get(nu), 'Spot', 'de spot blijft de spot, ook met een kaart per punt');
});
it('binnen de dag: de minuutreeks tussen de slotkoers en de spot', () => {
const intraday = [[vandaag + 3600000, 11.5], [vandaag + 7200000, 11.8]];
const use = rangeSeries(grid, 'day', 12, nu, intraday);
const bron = rangePointSources(use, { range: 'day', spot: 12, nowMs: nu, live: 'Spot', intraday: 'Minuten', days });
assert.equal(use.length, 4);
assert.equal(bron.get(use[0][0]), `Atreides Services, koers van ${dagTekst(d0 + DAG)}`);
assert.equal(bron.get(use[1][0]), 'Minuten');
assert.equal(bron.get(use[2][0]), 'Minuten');
assert.equal(bron.get(nu), 'Spot');
});
it('binnen de dag zonder reeks: twee punten, en geen van beide heet minuutreeks', () => {
const use = rangeSeries(grid, 'day', 12, nu, null);
const bron = rangePointSources(use, { range: 'day', spot: 12, nowMs: nu, live: 'Spot', intraday: 'Minuten', days });
assert.equal(use.length, 2);
assert.equal(bron.get(vandaag), 'Spot');
assert.notEqual(bron.get(use[0][0]), 'Minuten');
});
it('zonder iets te zeggen: geen bron, ook niet voor een dag vóór de eerste eigen rij', () => {
assert.equal(rangePointSources([], { range: 'week' }).size, 0);
assert.equal(rangePointSources(null, { range: 'week' }).size, 0);
const bron = rangePointSources([[d0 - DAG, 9], [d0, 10]], { range: 'week', nowMs: nu, days });
assert.equal(bron.has(d0 - DAG), false);
assert.equal(bron.get(d0), 'CoinGecko');
});
it('namedDaySources: ids naar namen, en een onbekende provider valt weg in plaats van verkeerd te heten', () => {
const dagBron = (providerId, sourceName = null) => ({ providerId, sourceName });
const byDay = new Map([[d0, dagBron(1)], [d0 + DAG, dagBron(2)], [d0 + 2 * DAG, dagBron(99)]]);
const named = namedDaySources(byDay, [{ id: 1, name: 'CoinGecko' }, { id: 2, name: 'Atreides Services' }]);
assert.deepEqual([...named], [[d0, 'CoinGecko'], [d0 + DAG, 'Atreides Services']]);
assert.equal(namedDaySources(byDay, null).size, 0);
});
it('namedDaySources: de bron achter een dagkoers van de dienst staat erbij, zoals bij elke koers van de dienst', () => {
const byDay = new Map([[d0, { providerId: 2, sourceName: 'Bitvavo' }]]);
const named = namedDaySources(byDay, [{ id: 2, name: 'Atreides Services' }]);
assert.equal(named.get(d0), 'Atreides Services (Bitvavo)');
});
});
describe('intradayStart: waar de reeks binnen de dag begint', () => {
const DAG = 86400000;
const nu = Date.parse('2026-10-09T13:00:00Z');
const middernacht = Math.floor(nu / DAG) * DAG;
it('het eind van de laatste slotdag vóór vandaag', () => {
assert.equal(intradayStart([[middernacht - 2 * DAG, 1], [middernacht - DAG, 2]], nu), middernacht);
});
it('een opgeslagen waarde van vandaag telt niet als slotkoers', () => {
assert.equal(intradayStart([[middernacht - DAG, 2], [middernacht, 3]], nu), middernacht);
});
it('na een weekend zonder slot: vanaf het eind van de laatste', () => {
assert.equal(intradayStart([[middernacht - 3 * DAG, 1]], nu), middernacht - 2 * DAG);
});
it('zonder reeks: vandaag middernacht', () => {
assert.equal(intradayStart([], nu), middernacht);
assert.equal(intradayStart(null, nu), middernacht);
});
});
describe('serviceOrder: de plek van de dienst in de keten', () => {
const app = (providerId, rank) => ({ providerId, rank, own: false });
const zelf = (providerId, rank) => ({ providerId, rank, own: true });
it('eerst: zelf gekozen, dan de dienst, dan de keuze van de app', () => {
const { ranks, service } = serviceOrder([app(1, 1), zelf(2, 2), app(3, 3)], 'first');
assert.deepEqual([...ranks], [[2, 1], [1, 3], [3, 4]]);
assert.equal(service, 2);
});
it('eerst zonder eigen keuze: de dienst bovenaan', () => {
const { ranks, service } = serviceOrder([app(1, 1)], 'first');
assert.equal(service, 1);
assert.equal(ranks.get(1), 2);
});
it('als terugval: alle gekozen bronnen, dan de dienst', () => {
const { ranks, service } = serviceOrder([zelf(2, 2), app(1, 1)], 'fallback');
assert.deepEqual([...ranks], [[1, 1], [2, 2]]);
assert.equal(service, 3);
});
});
describe('matrixCardHtml: de dienst als vaste kolom', () => {
const bron = { id: 7, name: 'Bitvavo', isActive: 1, coverage: ['BTC'], endpoints: [{ capability: 'spot' }] };
const btc = { id: 1, symbol: 'BTC', kind: 'crypto', name: 'Bitcoin' };
const ctx = (mode, own, covered = [1]) => ({
alle: [bron], providers: [bron], rateSource: new Map(),
cell: new Map([['1|spot|7', { rank: 1, proposed: false, own }]]),
service: { mode, covered: new Set(covered) },
});
const nummers = html => [...html.matchAll(/class="mx-rank[^"]*">(\d+)</g)].map(m => Number(m[1]));
it('stand eerst, Bitvavo door de app gekozen: de dienst op 1, Bitvavo op 2', () => {
const html = matrixCardHtml(btc, ctx('first', false));
assert.match(html, /Atreides Services/);
assert.deepEqual(nummers(html), [2, 1]);
assert.match(html, /mx-svc/);
});
it('stand eerst, Bitvavo zelf gekozen: Bitvavo op 1, de dienst op 2', () => {
assert.deepEqual(nummers(matrixCardHtml(btc, ctx('first', true))), [1, 2]);
});
it('de dienstkolom is niet klikbaar', () => {
const html = matrixCardHtml(btc, ctx('first', false));
assert.doesNotMatch(html, /class="mx-cell"[^>]*>[^<]*<span class="mx-rank[^"]*is-service/);
});
it('stand uit, of een asset dat de dienst niet dekt: geen kolom', () => {
assert.doesNotMatch(matrixCardHtml(btc, ctx('off', false)), /Atreides Services/);
assert.doesNotMatch(matrixCardHtml(btc, ctx('first', false, [])), /Atreides Services/);
});
});
+21 -1
View File
@@ -1,4 +1,4 @@
// Generated, do not edit. sha256:f04b67a8f0dbd9eb295b842585345e76f56ef4fd377d5010897bcf893dbd0395 // Generated, do not edit. sha256:0196b96345c180a741780bad0241a094640e956c6972ad08f5b4f400181c36c5
import { describe, it, beforeEach } from 'node:test'; import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs'; import { installDom } from '../helpers/mini-dom.mjs';
@@ -98,6 +98,26 @@ describe('a panel that would scroll gets wider on opening', () => {
assert.equal(panel.classList.contains('is-wide'), false); assert.equal(panel.classList.contains('is-wide'), false);
}); });
it('a click on the icon pins the panel, and a click outside, Escape or the icon again closes it', () => {
configureHint();
setPageHint(page, { title: 'x', rows: ['<a href="#">link</a>'] });
const hint = () => mount.querySelector('.hint');
const btn = () => mount.querySelector('.hint-btn');
dom.click(btn());
assert.ok(hint().classList.contains('is-open'));
assert.equal(btn().getAttribute('aria-expanded'), 'true');
dom.click(mount.querySelector('.hint-panel a'));
assert.ok(hint().classList.contains('is-open'), 'a click inside the panel keeps it open');
dom.click(page);
assert.equal(hint().classList.contains('is-open'), false);
dom.click(btn());
dom.key(dom.document.body, 'Escape');
assert.equal(hint().classList.contains('is-open'), false);
dom.click(btn());
dom.click(btn());
assert.equal(hint().classList.contains('is-open'), false);
});
it('binds its listeners once per mount, however often a page announces', () => { it('binds its listeners once per mount, however often a page announces', () => {
const before = mount.listeners.length; const before = mount.listeners.length;
setPageHint(page, { title: 'a', rows: [] }); setPageHint(page, { title: 'a', rows: [] });
+18 -2
View File
@@ -1,7 +1,7 @@
// Generated, do not edit. sha256:3b34cf97cc5aa20ba08ae8a6246f0235b943e5e85fc0d31e21707e06b2c2c371 // Generated, do not edit. sha256:d235c44eb19e911ab77974e75ddb595cbc8249ee72a4b4a543123c373e4e6b5f
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay } from '../../client/wealth/return-view.js'; import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay, flatLine } from '../../client/wealth/return-view.js';
import { dagreeks, seriesFor, meetVenster } from '../../client/wealth/series.js'; import { dagreeks, seriesFor, meetVenster } from '../../client/wealth/series.js';
import { windowTwr } from '../../client/wealth/performance.js'; import { windowTwr } from '../../client/wealth/performance.js';
@@ -94,6 +94,22 @@ describe('returnLine', () => {
}); });
}); });
describe('flatLine', () => {
it('geeft nul over de periode, eindigend op vandaag, oudste eerst', () => {
const f = flatLine(7, '2026-10-29');
assert.equal(f.amount, 0);
assert.equal(f.pct, 0);
assert.equal(f.labels.length, 7);
assert.equal(f.labels[0], '2026-10-23');
assert.equal(f.labels.at(-1), '2026-10-29');
assert.ok(f.points.every(p => p.cumulative === 0));
});
it('heeft minstens twee punten, ook voor één dag', () => {
assert.deepEqual(flatLine(1, '2026-10-29').labels, ['2026-10-28', '2026-10-29']);
});
});
describe('seriesFor met een groep', () => { describe('seriesFor met een groep', () => {
it('is met alle assets erin hetzelfde als het totaal', () => { it('is met alle assets erin hetzelfde als het totaal', () => {
const r = reeks(); const r = reeks();