Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user