Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
// Generated, do not edit. sha256:f7ff570558c71c4401b25418a5f1ab453ce90d9a53873cee3fc08aace66e3174
import { t, localeTag } from '../i18n/index.js';
import { CLOCKS } from '../../shared/config.js';
import { durationParts, exchangeStatus, localClock } from './exchange-times.js';
const WEEKDAY_NUM = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const pad2 = n => String(n).padStart(2, '0');
const hhmm = (h, m) => `${pad2(h)}:${pad2(m)}`;
const cityFromTz = tz => tz.split('/').pop().replace(/_/g, ' ');
const _dateFmt = new Map();
function tzDate(tz, now) {
const key = `${localeTag()}|${tz}`;
let f = _dateFmt.get(key);
if (!f) { f = new Intl.DateTimeFormat(localeTag(), { timeZone: tz, weekday: 'short', day: 'numeric', month: 'short' }); _dateFmt.set(key, f); }
return f.format(now);
}
function clockHint(isLocal, own, h, m) {
if (isLocal) return t('clocks.hoverLocal');
let d = ((h * 60 + m) - (own.h * 60 + own.m)) % 1440;
if (d > 720) d -= 1440; else if (d <= -720) d += 1440;
if (d === 0) return t('clocks.hoverSame');
const abs = Math.abs(d), uur = Math.floor(abs / 60), min = abs % 60;
const dur = [];
if (uur) dur.push(`${uur} uur`);
if (min) dur.push(`${min} ${min === 1 ? 'minuut' : 'minuten'}`);
return `${dur.join(' en ')} ${d < 0 ? 'achter' : 'voor'}`;
}
const _offFmt = new Map();
function tzOffsetMinutes(tz, now) {
let f = _offFmt.get(tz);
if (!f) { f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); _offFmt.set(tz, f); }
const p = {}; for (const part of f.formatToParts(now)) p[part.type] = part.value;
const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
return Math.round((asUTC - now.getTime()) / 60000);
}
const norm1440 = o => ((o % 1440) + 1440) % 1440;
const circDist = (a, b) => { const d = Math.abs(a - b); return Math.min(d, 1440 - d); };
function spreadCityKeys(now) {
const localOff = norm1440(tzOffsetMinutes(Intl.DateTimeFormat().resolvedOptions().timeZone, now));
const chosen = [localOff];
const cand = CLOCKS.CITIES.map(c => ({ key: c.key, off: norm1440(tzOffsetMinutes(c.tz, now)) }));
const picked = [];
for (let i = 0; i < 3 && cand.length; i++) {
let best = -1, bestMin = -1;
cand.forEach((c, idx) => {
let m = Infinity;
for (const o of chosen) m = Math.min(m, circDist(c.off, o));
if (m > bestMin) { bestMin = m; best = idx; }
});
if (best < 0) break;
chosen.push(cand[best].off);
picked.push(cand[best].key);
cand.splice(best, 1);
}
return picked;
}
function clockKeys(instance) {
const p = instance && instance.params;
const c = p && p.cities;
return Array.isArray(c) ? c : ['local', ...spreadCityKeys(new Date())];
}
function faceSvg({ seconds = false, mini = false } = {}) {
let ticks = '';
for (let i = 0; i < 12; i++) {
const major = i % 3 === 0;
if (mini && !major) continue;
const a = (i * 30) * Math.PI / 180;
const r1 = major ? 38 : 42.5;
const x1 = (50 + Math.sin(a) * r1).toFixed(2), y1 = (50 - Math.cos(a) * r1).toFixed(2);
const x2 = (50 + Math.sin(a) * 46).toFixed(2), y2 = (50 - Math.cos(a) * 46).toFixed(2);
ticks += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" class="clockw-tick${major ? ' is-major' : ''}"/>`;
}
const sec = seconds ? '<line class="clockw-hand clockw-sec" x1="50" y1="58" x2="50" y2="14"/>' : '';
return `<svg class="clockw-face" viewBox="0 0 100 100" aria-hidden="true">
<circle class="clockw-dial" cx="50" cy="50" r="46"/>
${ticks}
<line class="clockw-hand clockw-hour" x1="50" y1="54" x2="50" y2="31"/>
<line class="clockw-hand clockw-min" x1="50" y1="56" x2="50" y2="20"/>
${sec}
<circle class="clockw-cap" cx="50" cy="50" r="2.3"/>
</svg>`;
}
function setHands(svg, h, m, s) {
if (!svg) return;
const hourA = ((h % 12) + m / 60) * 30;
const minA = (m + (s == null ? 0 : s) / 60) * 6;
const hour = svg.querySelector('.clockw-hour');
const min = svg.querySelector('.clockw-min');
if (hour) hour.setAttribute('transform', `rotate(${hourA.toFixed(2)} 50 50)`);
if (min) min.setAttribute('transform', `rotate(${minA.toFixed(2)} 50 50)`);
const sec = svg.querySelector('.clockw-sec');
if (sec && s != null) sec.setAttribute('transform', `rotate(${(s * 6).toFixed(2)} 50 50)`);
}
function tzParts(tz, now) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(now);
const part = type => parts.find(p => p.type === type)?.value;
let h = Number(part('hour'));
if (h === 24) h = 0;
return { wd: WEEKDAY_NUM[part('weekday')], h, m: Number(part('minute')), s: Number(part('second')) };
}
function exchangeState(ex, now) {
const { h, m } = tzParts(ex.tz, now);
const st = exchangeStatus(ex, now);
const minutes = (a, b) => (a - b) / 60000;
return {
ex, hh: h, mm: m, isOpen: st.isOpen, status: st,
sinceOpen: st.isOpen ? minutes(now, st.openedAt) : null,
untilOpen: st.opensAt ? minutes(st.opensAt, now) : Infinity,
};
}
const DURATION_KEY = { min: 'clocks.durMin', hourMin: 'clocks.durHourMin', hours: 'clocks.durHours', days: 'clocks.durDays' };
function durationText(ms) {
const p = durationParts(ms);
if (p.unit === 'days' && p.n === 1) return t('clocks.durDay');
return t(DURATION_KEY[p.unit], { n: p.n, m: p.m });
}
function rowTexts(s, now) {
const st = s.status;
if (st.isOpen) {
return { sub: t('clocks.openUntil', { tijd: localClock(st.closesAt, now).time }), right: t('clocks.closesIn', { duur: durationText(st.closesAt - now) }) };
}
if (!st.opensAt) return { sub: '', right: '' };
const c = localClock(st.opensAt, now);
const sub = c.otherDay ? t('clocks.opensOn', { dag: c.weekday, tijd: c.time }) : t('clocks.opensAt', { tijd: c.time });
return { sub, right: t('clocks.opensIn', { duur: durationText(st.opensAt - now) }) };
}
function orderStates(states) {
const open = states.filter(s => s.isOpen).sort((a, b) => a.sinceOpen - b.sinceOpen);
const shut = states.filter(s => !s.isOpen).sort((a, b) => a.untilOpen - b.untilOpen);
return [...open, ...shut];
}
function dedupeStates(states) {
const bySig = new Map();
for (const s of states) {
const sig = `${s.ex.tz}|${s.ex.open}|${s.ex.close}|${s.ex.days.join('')}`;
const hit = bySig.get(sig);
if (hit) hit.names.push(s.ex.name);
else bySig.set(sig, { ...s, names: [s.ex.name] });
}
return [...bySig.values()].map(s => ({ ...s, ex: { ...s.ex, name: s.names.join(' / ') } }));
}
function worldEntries(instance) {
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const resolve = k => k === 'local'
? { key: 'local', name: cityFromTz(localTz), tz: localTz, local: true }
: (CLOCKS.CITIES.find(c => c.key === k) ? { ...CLOCKS.CITIES.find(c => c.key === k), local: false } : null);
const entries = clockKeys(instance).map(resolve).filter(Boolean);
const local = entries.find(e => e.local);
const others = entries.filter(e => !e.local);
if (!local) return others;
const mid = Math.floor(others.length / 2);
return [...others.slice(0, mid), local, ...others.slice(mid)];
}
function worldClockMarkup(e) {
return `<div class="clockw-clock${e.local ? ' is-local' : ''}" data-tz="${e.tz}" data-local="${e.local ? '1' : '0'}">
<div class="clockw-clock-face">${faceSvg({ seconds: e.local })}</div>
<div class="clockw-clock-cap">
<span class="clockw-clock-name">${e.name}</span>
<span class="clockw-clock-time"></span>
<span class="clockw-clock-date"></span>
</div>
</div>`;
}
function rowMarkup(s) {
return `<div class="clockw-row" data-ex="${s.ex.key}">
<span class="clockw-mini">${faceSvg({ mini: true })}</span>
<span class="clockw-ex-text">
<span class="clockw-ex-name">${s.ex.name}</span>
<span class="clockw-ex-sub"></span>
</span>
<span class="clockw-open"></span>
</div>`;
}
function applyRow(row, s, now) {
setHands(row.querySelector('.clockw-face'), s.hh, s.mm, null);
row.querySelector('.clockw-face').classList.toggle('is-open', s.isOpen);
row.classList.toggle('is-open', s.isOpen);
const { sub, right } = rowTexts(s, now);
row.querySelector('.clockw-ex-sub').textContent = sub;
row.querySelector('.clockw-open').textContent = right;
}
function flashRow(listEl, key) {
const row = listEl.querySelector(`[data-ex="${key}"]`);
if (!row) return;
row.classList.remove('is-flash');
void row.offsetWidth;
row.classList.add('is-flash');
setTimeout(() => row.classList.remove('is-flash'), 1600);
}
function showExchanges(instance) {
const p = instance && instance.params;
return !!(p && p.showExchanges === true);
}
export function renderClocksWidget(host, instance) {
if (host._clockTimer) { clearInterval(host._clockTimer); host._clockTimer = null; }
const entries = worldEntries(instance);
const showEx = showExchanges(instance);
host.innerHTML = `<div class="card clockw${showEx ? '' : ' clockw--no-ex'}">
<div class="t-label">${t('dashboard.widget.clocks')}</div>
<div class="clockw-world">${entries.map(worldClockMarkup).join('')}</div>
${showEx ? `<div class="t-label t-label--ter clockw-list-head">${t('clocks.listHead')}</div><div class="clockw-list" data-clock-list></div>` : ''}
</div>`;
const listEl = host.querySelector('[data-clock-list]');
const prevOpen = host._exOpen || {};
let shownKeys = '';
const update = () => {
if (!host.isConnected) { clearInterval(host._clockTimer); host._clockTimer = null; return; }
const now = new Date();
const own = tzParts(Intl.DateTimeFormat().resolvedOptions().timeZone, now);
host.querySelectorAll('.clockw-clock').forEach(el => {
const { h, m, s } = tzParts(el.dataset.tz, now);
setHands(el.querySelector('.clockw-face'), h, m, el.dataset.local === '1' ? s : null);
const time = el.querySelector('.clockw-clock-time');
if (time) time.textContent = hhmm(h, m);
const date = el.querySelector('.clockw-clock-date');
if (date) date.textContent = tzDate(el.dataset.tz, now);
el.title = clockHint(el.dataset.local === '1', own, h, m);
});
if (!listEl) return;
const states = dedupeStates(CLOCKS.EXCHANGES.map(ex => exchangeState(ex, now)));
const ordered = orderStates(states).slice(0, CLOCKS.LIST_MAX);
const keys = ordered.map(s => s.ex.key).join(',');
if (keys !== shownKeys) { shownKeys = keys; listEl.innerHTML = ordered.map(rowMarkup).join(''); }
ordered.forEach(s => { const row = listEl.querySelector(`[data-ex="${s.ex.key}"]`); if (row) applyRow(row, s, now); });
states.forEach(s => { if (s.isOpen && prevOpen[s.ex.key] === false) flashRow(listEl, s.ex.key); });
states.forEach(s => { prevOpen[s.ex.key] = s.isOpen; });
host._exOpen = prevOpen;
};
update();
host._clockTimer = setInterval(update, 1000);
}
export function renderClocksSettings(host, instance, commit) {
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const selected = new Set(clockKeys(instance));
const showEx = showExchanges(instance);
const entries = [{ key: 'local', name: `${t('clocks.localLabel')} (${cityFromTz(localTz)})` }, ...CLOCKS.CITIES];
host.innerHTML = `<div class="clockw-settings">
<label class="clockw-check"><span class="toggle-switch"><input type="checkbox" data-ex-toggle${showEx ? ' checked' : ''}/><span class="toggle-track"></span></span><span class="clockw-check-name">${t('clocks.showExchanges')}</span></label>
<div class="clockw-settings-sep"></div>
<div class="t-label t-label--ter clockw-settings-title">${t('clocks.chooseClocks')}</div>
${entries.map(e => `<label class="clockw-check"><span class="toggle-switch"><input type="checkbox" data-city="${e.key}"${selected.has(e.key) ? ' checked' : ''}/><span class="toggle-track"></span></span><span class="clockw-check-name">${e.name}</span></label>`).join('')}
</div>`;
host.querySelector('.clockw-settings').addEventListener('change', e => {
const exToggle = e.target.closest('input[data-ex-toggle]');
if (exToggle) {
instance.params = { ...(instance.params || {}), showExchanges: exToggle.checked };
commit({ resize: true });
return;
}
const cb = e.target.closest('input[data-city]');
if (!cb) return;
const set = new Set(clockKeys(instance));
if (cb.checked) set.add(cb.dataset.city); else set.delete(cb.dataset.city);
instance.params = { ...(instance.params || {}), cities: [...set] };
commit({ resize: true });
});
}
+76
View File
@@ -0,0 +1,76 @@
// Generated, do not edit. sha256:f7c492e9043516674843077cdedb9f9190b5176696652a8825a3e593c111d7ea
import { localeTag } from '../i18n/index.js';
const MIN = 60 * 1000;
const WEEKDAY_NUM = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const _partsFmt = new Map();
function wallParts(tz, instant) {
let f = _partsFmt.get(tz);
if (!f) {
f = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hourCycle: 'h23', weekday: 'short',
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
_partsFmt.set(tz, f);
}
const p = {};
for (const part of f.formatToParts(instant)) p[part.type] = part.value;
return { y: +p.year, mo: +p.month, d: +p.day, wd: WEEKDAY_NUM[p.weekday], h: +p.hour % 24, mi: +p.minute };
}
export function wallToInstant(tz, y, mo, d, h, mi) {
const target = Date.UTC(y, mo - 1, d, h, mi);
let t = target;
for (let i = 0; i < 2; i++) {
const w = wallParts(tz, new Date(t));
t += target - Date.UTC(w.y, w.mo - 1, w.d, w.h, w.mi);
}
return new Date(t);
}
const hm = (s) => s.split(':').map(Number);
export function exchangeStatus(ex, now = new Date()) {
const [oh, om] = hm(ex.open);
const [ch, cm] = hm(ex.close);
const today = wallParts(ex.tz, now);
for (let d = 0; d < 8; d++) {
const day = new Date(Date.UTC(today.y, today.mo - 1, today.d + d));
if (!ex.days.includes(day.getUTCDay())) continue;
const y = day.getUTCFullYear(), mo = day.getUTCMonth() + 1, dd = day.getUTCDate();
const opens = wallToInstant(ex.tz, y, mo, dd, oh, om);
const closes = wallToInstant(ex.tz, y, mo, dd, ch, cm);
if (now < opens) return { isOpen: false, openedAt: null, closesAt: null, opensAt: opens };
if (now < closes) return { isOpen: true, openedAt: opens, closesAt: closes, opensAt: null };
}
return { isOpen: false, openedAt: null, closesAt: null, opensAt: null };
}
export function durationParts(ms) {
const minutes = Math.max(1, Math.ceil(ms / MIN));
if (minutes < 60) return { unit: 'min', n: minutes };
if (minutes < 180) return { unit: 'hourMin', n: Math.floor(minutes / 60), m: minutes % 60 };
if (minutes < 24 * 60) return { unit: 'hours', n: Math.round(minutes / 60) };
return { unit: 'days', n: Math.round(minutes / (24 * 60)) };
}
const _clockFmt = new Map();
export function localClock(instant, now = new Date(), tz = undefined) {
const tag = localeTag();
const key = `${tag}|${tz || ''}`;
let f = _clockFmt.get(key);
if (!f) {
f = {
time: new Intl.DateTimeFormat(tag, { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }),
day: new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }),
weekday: new Intl.DateTimeFormat(tag, { timeZone: tz, weekday: 'short' }),
};
_clockFmt.set(key, f);
}
return {
time: f.time.format(instant),
otherDay: f.day.format(instant) !== f.day.format(now),
weekday: f.weekday.format(instant),
};
}
+53
View File
@@ -0,0 +1,53 @@
// Generated, do not edit. sha256:1196ce67207fc0b54b2fc602d2f16cc37c4414638c2f9a09a7c16d83c58acd2f
import { apiMetricsGlobal } from '../data/api-metrics.js';
import { getSettings } from '../data/preferences.js';
import { t } from '../i18n/index.js';
import { esc } from '../ui/escape.js';
import { fmtInt } from '../ui/format.js';
import { CONFIG, METRICS } from '../../shared/config.js';
function sinceBucket(minutes) {
return new Date(Date.now() - minutes * 60000).toISOString().slice(0, 16);
}
function failState(calls, fail) {
if (!calls) return 'ok';
const ratio = fail / calls;
if (ratio >= METRICS.FAIL_BAD) return 'bad';
if (ratio >= METRICS.FAIL_WARN) return 'warn';
return 'ok';
}
function tileHtml(label, value, state) {
return `<div class="metric-tile">
<span class="metric-dot is-${state}"></span>
<div class="metric-meta"><div class="t-value">${value}</div><div class="t-label t-label--ter">${label}</div></div>
</div>`;
}
export async function renderMetricsWidget(host) {
try {
const settings = await getSettings();
const schema = settings ? settings.version : null;
const dbState = schema === CONFIG.SCHEMA_VERSION ? 'ok' : 'warn';
const m = await apiMetricsGlobal(sinceBucket(METRICS.WINDOW_MINUTES));
const errState = failState(m.calls, m.fail);
const errPct = m.calls ? Math.round((m.fail / m.calls) * 100) : 0;
const tiles = [
tileHtml(t('sysmetrics.db'), `v${schema != null ? schema : '?'}`, dbState),
tileHtml(t('sysmetrics.calls'), fmtInt(m.calls), m.calls ? 'ok' : 'warn'),
tileHtml(t('sysmetrics.errors'), `${errPct}%`, errState),
];
host.innerHTML = `<div class="card metrics-card">
<div class="t-label">${t('dashboard.widget.metrics')}</div>
<div class="metrics-strip" style="margin-top:0.6rem">${tiles.join('')}</div>
<div class="t-muted-cell" style="margin-top:0.5rem">${t('sysmetrics.window')} ${METRICS.WINDOW_MINUTES} min</div>
</div>`;
} catch (err) {
console.error('[metrics-widget]', err);
host.innerHTML = `<div class="card"><div class="t-label">${t('dashboard.widget.metrics')}</div><div class="t-body-sm t-muted">⚠ ${esc(err.message)}</div></div>`;
}
}
+32
View File
@@ -0,0 +1,32 @@
// Generated, do not edit. sha256:bee329db148fa1f318902e8add4230015eac6bdf80b4700aa7a993e8d1c5e7ee
import { momentumOf } from '../price/momentum.js';
import { esc } from '../ui/escape.js';
import { t } from '../i18n/index.js';
export function momentumOn(instance) {
const p = instance && instance.params;
return !(p && p.momentum === false);
}
export function momentumDotHtml(assetId, on) {
if (!on || assetId == null) return '';
const { up, down, tone } = momentumOf(assetId);
if (!tone) return '';
const n = up + down;
const pct = Math.round(((tone === 'up' ? up : down) / n) * 100);
const hint = t(tone === 'up' ? 'dashboard.momentumUp' : 'dashboard.momentumDown', { pct, n });
return `<span class="momentum-dot is-${tone}" title="${esc(hint)}"></span>`;
}
export function renderMomentumSetting(host, instance, commit) {
const row = document.createElement('div');
row.className = 'dash-set-row';
row.innerHTML = `<label class="clockw-check"><span class="toggle-switch"><input type="checkbox" data-momentum${momentumOn(instance) ? ' checked' : ''}/><span class="toggle-track"></span></span><span class="clockw-check-name">${esc(t('dashboard.momentum'))}</span></label>`;
host.appendChild(row);
row.addEventListener('change', e => {
const box = e.target.closest('[data-momentum]');
if (!box) return;
instance.params = { ...(instance.params || {}), momentum: box.checked };
commit();
});
}
+23
View File
@@ -0,0 +1,23 @@
// Generated, do not edit. sha256:9a4b9605453795f4e63a44a3dbe52ca8e7e112a8edb0a2dcc85664207e4801cc
import { DASHBOARD } from '../../shared/config.js';
import { createRegistry } from '../../shared/registry.js';
import { locked } from '../license.js';
const widgets = createRegistry('widget', {
validate: (e) => { if (!DASHBOARD.WIDGETS[e.id]) throw new Error(`Unknown widget type '${e.id}'.`); },
});
export function registerWidget(type, { render, settings = null, feature = null }) {
widgets.register({ id: type, render, settings, feature });
}
export function availableWidgets() {
const renderers = {};
const settings = {};
for (const w of widgets.list()) {
if (w.feature && locked(w.feature)) continue;
renderers[w.id] = w.render;
if (w.settings) settings[w.id] = w.settings;
}
return { renderers, settings };
}
+114
View File
@@ -0,0 +1,114 @@
// Generated, do not edit. sha256:ae0f941ec7a830f9f94ae85e37de1157b6e15f16cc2e9dc3f0c5c7243d3eaafd
import { listAssets } from '../data/assets.js';
import { listAccounts } from '../data/accounts.js';
import { getPref, setPref } from '../data/preferences.js';
import { t } from '../i18n/index.js';
import { esc } from '../ui/escape.js';
import { fmtBaseSigned, fmtPct, pctTone, colouredSigns } from '../ui/format.js';
import { assetLabel, assetLabelHtml, byKindAndLabel } from '../ui/asset-label.js';
import { CHEVRON_RIGHT } from '../ui/icons.js';
import { rangeLabel, rangeCycleButton, wireRangeCycle } from '../ui/range-cycle.js';
import { isOwnWealth } from '../../shared/wealth-scope.js';
import { ENUMS, TARGETS, targetHash } from '../../shared/config.js';
import { dagreeksVanNu } from '../wealth/series.js';
import { periodChoices } from '../wealth/return-view.js';
import { lineForData, drawLine } from '../wealth/return-dialog.js';
const PREF_SCOPE = 'widget.return';
const RANGES = Object.freeze({ day: 1, week: 7, month: 30, year: 365, all: 0 });
const PERIOD_OF = Object.freeze({ day: 'd1', week: 'd7', month: 'd30', year: 'd365', all: 'start' });
const DEFAULT_RANGE = 'month';
const dataOf = instance => (typeof instance?.params?.data === 'string' ? instance.params.data : 'total');
function titleHtml(data, assets) {
const base = esc(t('dashboard.widget.return'));
if (data.startsWith('asset:')) {
const a = assets.find(x => x.id === Number(data.slice(6)));
return a ? `${base}: ${assetLabelHtml(a)}` : base;
}
if (data.startsWith('kind:')) return `${base}: ${esc(t('enums.asset_kind.' + data.slice(5)))}`;
return base;
}
export async function renderReturnWidget(host, instance) {
const data = dataOf(instance);
const key = instance?.instId || 'return';
const [accounts, assets, stored] = await Promise.all([
listAccounts(), listAssets(), getPref(PREF_SCOPE, key, DEFAULT_RANGE).catch(() => DEFAULT_RANGE),
]);
let range = RANGES[stored] != null ? stored : DEFAULT_RANGE;
host.innerHTML = `<div class="card return-card">
<div class="flex-between">
<div class="t-label">${titleHtml(data, assets)}</div>
<a class="wealth-detail-btn card-go-btn" href="${targetHash(TARGETS.WEALTH)}" title="${esc(t('home.toWealth'))}" aria-label="${esc(t('home.toWealth'))}">${CHEVRON_RIGHT}</a>
</div>
<div class="return-w-amount sigw-price" data-return-amount>…</div>
<div class="flex-between spot-sub">
<div class="return-w-pct" data-return-pct></div>
<div class="range-cycle" data-return-range>${rangeCycleButton(range)}</div>
</div>
<div class="return-view--widget"><div class="return-plot"><canvas data-return-chart></canvas></div></div>
</div>`;
const amountEl = host.querySelector('[data-return-amount]');
const pctEl = host.querySelector('[data-return-pct]');
let chart = null;
let gen = 0;
try {
const excludedAccounts = new Set(accounts.filter(ac => !isOwnWealth(ac)).map(ac => ac.id));
const reeks = await dagreeksVanNu({ excludedAccounts });
if (!host.isConnected) return;
const held = assets.filter(a => (reeks.perAsset?.get(a.id) || []).some(v => v > 0));
const periods = periodChoices(reeks.dagen?.length || 0, null);
const draw = async () => {
const g = ++gen;
const btn = host.querySelector('.range-cycle-btn');
if (btn) btn.textContent = rangeLabel(range);
const venster = periods.find(p => p.key === PERIOD_OF[range])?.venster || null;
const line = lineForData({ reeks, assets: held, data, venster });
amountEl.innerHTML = line.amount == null ? '-'
: `<span class="val">${esc(fmtBaseSigned(line.amount, 'both'))}</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 = null;
let canvas = host.querySelector('[data-return-chart]');
const plot = host.querySelector('.return-plot');
if (line.points.length < 2) { plot.innerHTML = `<div class="t-muted-cell">${esc(t('wealth.noChart'))}</div>`; return; }
if (!canvas) { plot.innerHTML = '<canvas data-return-chart></canvas>'; canvas = plot.firstElementChild; }
const c = await drawLine(canvas, reeks, line.points).catch(() => null);
if (g !== gen || !host.isConnected) { c?.destroy(); return; }
chart = c;
};
await draw();
wireRangeCycle(host.querySelector('[data-return-range]'), () => range, next => {
range = next;
setPref(PREF_SCOPE, key, range).catch(() => { });
draw();
}, RANGES);
} catch (err) {
console.error('[return-widget]', err);
amountEl.textContent = '⚠ ' + err.message;
}
}
export async function renderReturnSettings(host, instance, commit) {
const assets = (await listAssets()).sort(byKindAndLabel);
const current = dataOf(instance);
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 row = document.createElement('div');
row.className = 'dash-set-row';
row.innerHTML = `<span class="t-label t-label--ter">${esc(t('wealth.returnData'))}</span>
<select class="input dash-set-select" data-return-setting>
${opt('total', t('wealth.returnTotal'))}
<optgroup label="${esc(t('wealth.returnKinds'))}">${kinds.map(k => opt(`kind:${k}`, t('enums.asset_kind.' + k))).join('')}</optgroup>
<optgroup label="${esc(t('wealth.returnAssets'))}">${assets.map(a => opt(`asset:${a.id}`, assetLabel(a))).join('')}</optgroup>
</select>`;
host.appendChild(row);
row.querySelector('[data-return-setting]').addEventListener('change', e => {
instance.params = { ...(instance.params || {}), data: e.target.value };
commit();
});
}
+43
View File
@@ -0,0 +1,43 @@
// Generated, do not edit. sha256:9910b171f8780710e55bbf6e2270b3758af0e766b12a3590b21420b1bc45cfe8
import { listAssets } from '../data/assets.js';
import { baseCurrency } from '../app-settings.js';
import { currentRate } from '../price/providers.js';
import { fmtMoney } from '../ui/format.js';
import { esc } from '../ui/escape.js';
import { t } from '../i18n/index.js';
export function secondCurrencyOf(instance, fallback = null) {
const p = instance && instance.params;
const v = p && Object.prototype.hasOwnProperty.call(p, 'second') ? p.second : fallback;
return typeof v === 'string' && v !== '' && v !== baseCurrency() ? v : null;
}
export async function secondCurrencyChoices() {
const base = baseCurrency();
const fiat = (await listAssets()).filter(a => a.kind === 'fiat' && a.symbol !== base);
const rates = await Promise.all(fiat.map(a => currentRate(a.symbol).catch(() => null)));
return fiat.filter((_, i) => rates[i] != null).map(a => a.symbol).sort();
}
export async function secondAmount(baseAmount, code, fmtOpts = {}) {
if (!code || baseAmount == null || !Number.isFinite(baseAmount)) return '';
const rate = await currentRate(code).catch(() => null);
return rate ? fmtMoney(baseAmount / rate, code, fmtOpts) : '';
}
export async function renderSecondCurrencySetting(host, instance, commit, fallback = null) {
const current = secondCurrencyOf(instance, fallback);
const choices = await secondCurrencyChoices();
if (current && !choices.includes(current)) choices.unshift(current);
const opties = [`<option value=""${current ? '' : ' selected'}>${esc(t('dashboard.secondNone'))}</option>`]
.concat(choices.map(c => `<option value="${esc(c)}"${c === current ? ' selected' : ''}>${esc(c)}</option>`)).join('');
const block = document.createElement('label');
block.className = 'dash-set-row';
block.innerHTML = `<span class="t-label t-label--ter">${esc(t('dashboard.secondCurrency'))}</span>
<select class="input dash-set-select" data-second>${opties}</select>`;
host.appendChild(block);
block.querySelector('select').addEventListener('change', e => {
instance.params = { ...(instance.params || {}), second: e.target.value || null };
commit();
});
}
+172
View File
@@ -0,0 +1,172 @@
// Generated, do not edit. sha256:a0b282c1ed4d4e3e20c57f97c21ad932b410360a0adabc7ef61e694ce97a0ed9
import { listAssets } from '../data/assets.js';
import { getPref, setPref } from '../data/preferences.js';
import { baseCurrency } from '../app-settings.js';
import { listPriceHistory } from '../data/price-history.js';
import { t } from '../i18n/index.js';
import { toDailyGrid, dailyGridSources } from '../signals/data.js';
import { liveSpotFor, basePriceOf, lastSpotSource } from '../price/providers.js';
import { listProviders } from '../data/providers.js';
import { fmtMoney, colouredSigns } from '../ui/format.js';
import { assetLabelHtml } from '../ui/asset-label.js';
import { priceFromBase } from '../../shared/units.js';
import { secondCurrencyOf, secondAmount, renderSecondCurrencySetting } from './second-currency.js';
import { mountSparkline, changePctHtml, sliceTone } from '../ui/sparkline.js';
import { RANGE_DAYS, SPOT_RANGE_DAYS, rangeSeries, intradayStart, rangeLabel, rangeCycleButton, wireRangeCycle, rangePointLabel, rangePointSources, namedDaySources } from '../ui/range-cycle.js';
import { loadIntraday } from '../price/intraday.js';
import { momentumOn, momentumDotHtml, renderMomentumSetting } from './momentum-dot.js';
import { loadMomentum } from '../price/momentum.js';
const MS_PER_DAY = 86400000;
function fromDateStr(days) {
return new Date(Date.now() - (days + 5) * MS_PER_DAY).toISOString().slice(0, 10);
}
const liveHosts = new Set();
let spotBound = false;
async function refreshOneSpot(entry) {
try {
const spot = await liveSpotFor(entry.asset);
if (!entry.host.isConnected) { liveHosts.delete(entry); return; }
const live = basePriceOf(spot);
if (live == null) return;
entry.setLive(live);
} catch { }
}
async function refreshAllSpots() {
for (const entry of liveHosts) {
if (!entry.host.isConnected) { liveHosts.delete(entry); continue; }
refreshOneSpot(entry);
}
}
export async function renderSpotSettings(host, instance, commit) {
await renderSecondCurrencySetting(host, instance, commit);
renderMomentumSetting(host, instance, commit);
}
export async function renderSpotWidget(host, instance) {
const assetId = instance && instance.params ? instance.params.assetId : null;
const asset = (await listAssets()).find(a => a.id === assetId);
if (!asset) {
host.innerHTML = `<div class="card"><div class="t-body-sm t-muted">\u26A0 ${t('spot.unknownAsset')}</div></div>`;
return;
}
const cur = baseCurrency();
const headHtml = assetLabelHtml(asset);
const inUnit = p => (p == null ? null : priceFromBase(p, asset));
const second = secondCurrencyOf(instance);
const fmtOpts = p => ({ decimals: p >= 1000 ? 0 : 2 });
let range = await getPref('widget.spot', String(assetId), 'week');
if (!SPOT_RANGE_DAYS[range]) range = 'week';
host.innerHTML = `<div class="card spot-card">
<div class="flex-between spot-head-row">
<div class="t-label spot-head">${headHtml}</div>
<div class="spot-price"><span id="spot-price">-</span><span class="amount-second hidden" id="spot-second"></span></div>
</div>
<div class="flex-between spot-sub">
<div class="spot-change" id="spot-change"></div>
<div class="range-cycle" id="spot-range">${rangeCycleButton(range)}</div>
</div>
<div class="spot-spark-wrap" id="spot-spark"></div>
<div class="t-body-sm t-neg hidden" id="spot-err"></div>
</div>`;
let grid = null;
let live = null;
let intraday = null;
let intradaySource = null;
let intradaySources = null;
let daySources = new Map();
const priceEl = host.querySelector('#spot-price');
const changeEl = host.querySelector('#spot-change');
const sparkEl = host.querySelector('#spot-spark');
const secondEl = host.querySelector('#spot-second');
async function showSecond(price) {
if (!second) return;
const tekst = await secondAmount(price, second, fmtOpts(price));
secondEl.textContent = tekst || '';
secondEl.classList.toggle('hidden', !tekst);
}
function setLive(basePrice) {
const price = inUnit(basePrice);
if (!Number.isFinite(price)) return;
live = price;
priceEl.textContent = fmtMoney(price, cur, fmtOpts(price));
showSecond(price);
draw();
refreshIntraday();
}
async function refreshIntraday() {
if (range !== 'day' || !grid) return;
const r = await loadIntraday(asset.id, intradayStart(grid));
if (!host.isConnected) return;
intraday = r.points ? r.points.map(([ms, p]) => [ms, inUnit(p)]) : null;
intradaySource = r.source || null;
intradaySources = r.sources || null;
draw();
}
const entry = { host, asset, setLive };
liveHosts.add(entry);
if (!spotBound) { document.addEventListener('tick:spot', refreshAllSpots); spotBound = true; }
function draw() {
const rb = host.querySelector('.range-cycle-btn');
if (rb) rb.textContent = rangeLabel(range);
if (!grid) return;
const nowMs = Date.now();
const use = rangeSeries(grid, range, live, nowMs, range === 'day' ? intraday : null);
const sources = rangePointSources(use, { range, spot: live, nowMs, live: lastSpotSource(asset),
intraday: intradaySource, intradayAt: intradaySources, days: daySources });
mountSparkline(sparkEl, use, {
tone: sliceTone(use),
formatValue: (p, ms) => rangePointLabel(range, fmtMoney(p, cur, { decimals: p >= 1000 ? 0 : 2 }), ms, sources.get(ms)),
});
changeEl.innerHTML = changePctHtml(use, momentumDotHtml(live != null ? asset.id : null, momentumOn(instance)), { fine: range === 'day', signs: colouredSigns() });
}
draw();
try {
const [spot, rows, , providers] = await Promise.all([
liveSpotFor(asset).catch(() => null),
Promise.resolve(await listPriceHistory(assetId, fromDateStr(RANGE_DAYS.year))),
loadMomentum(asset.id),
listProviders().catch(() => []),
]);
try { grid = toDailyGrid(rows).map(([d, p]) => [d, inUnit(p)]); }
catch { grid = null; }
daySources = namedDaySources(dailyGridSources(rows), providers);
const spotNu = inUnit(basePriceOf(spot));
const lastClose = grid && grid.length ? grid[grid.length - 1][1] : null;
if (spotNu != null) live = spotNu;
const price = spotNu != null ? spotNu : lastClose;
priceEl.textContent = price != null ? fmtMoney(price, cur, fmtOpts(price)) : '-';
if (price != null) showSecond(price);
if (!grid) sparkEl.innerHTML = `<div class="spot-spark-empty">${t('spot.noHistory')}</div>`;
else { draw(); refreshIntraday(); }
} catch (err) {
console.error('[spot-widget]', err);
const e = host.querySelector('#spot-err');
if (e) { e.classList.remove('hidden'); e.textContent = '\u26A0 ' + err.message; }
}
wireRangeCycle(host.querySelector('#spot-range'), () => range, next => {
range = next;
setPref('widget.spot', String(assetId), range).catch(() => { });
draw();
refreshIntraday();
}, SPOT_RANGE_DAYS);
}
+42
View File
@@ -0,0 +1,42 @@
// Generated, do not edit. sha256:e8b81d0ee6b08d9e5a1155c5a9b2d57a27a3e3e4cf29c9e9449b7eaea522f722
export const STANDING_GROUPINGS = Object.freeze(['account', 'accountKind', 'asset', 'assetKind']);
export const standingShowsToday = params => !(params && params.showToday === false);
export const standingGroupingOf = v => (STANDING_GROUPINGS.includes(v) ? v : 'account');
const KEYS = {
account: r => (r.account && r.account.id != null ? `a${r.account.id}` : 'none'),
accountKind: r => (r.account && r.account.kind ? r.account.kind : 'none'),
asset: r => `s${r.asset.id}`,
assetKind: r => r.asset.kind,
};
export function groupStanding(rows, by) {
const keyOf = KEYS[standingGroupingOf(by)];
const groups = new Map();
for (const r of rows) {
const key = keyOf(r);
let g = groups.get(key);
if (!g) { g = { key, sample: r, value: 0, origins: [] }; groups.set(key, g); }
g.value += r.value || 0;
g.origins.push({ origin: r.origin, asset: r.asset });
}
return [...groups.values()].sort((a, b) => b.value - a.value);
}
export function standingToday(rows, by, today) {
const out = new Map();
const grouping = standingGroupingOf(by);
if (!today?.perAsset || (grouping !== 'asset' && grouping !== 'assetKind')) return out;
const seen = new Set();
for (const r of rows) {
if (seen.has(r.asset.id)) continue;
seen.add(r.asset.id);
const amount = today.perAsset.get(r.asset.id);
if (amount == null) continue;
const key = KEYS[grouping](r);
out.set(key, (out.get(key) || 0) + amount);
}
return out;
}