// 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 = '- ';
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 = `
`;
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
? `${esc(t('priceHistory.gapsMore', { n: gaten.total - gaten.rows.length }))}
` : '';
const html = [...kaarten.values()].map(kaartHtml).join('');
host.innerHTML = `${html ? `${html}
` : ''}${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 => `${attentionDotHtml([{ severity: m.severity, label: m.tekst, fade: 'full' }])}${esc(m.tekst)} `;
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 => ` ${esc(soortTelling(k, counts[k]))} `)
.join('')
: `${esc(t('priceHistory.cardNoPrices'))} `;
const periode = dagen.length ? `${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))} ` : '';
const melding = meldingenVan(k).map(meldingHtml).join('');
return `
${assetLabelHtml(asset)}
${esc(t('enums.asset_kind.' + asset.kind))}
${periode}${telling}
${melding}
`;
}
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 = `${esc(titel)} `;
x += r.days;
return rect;
}).join('');
strookEl.innerHTML = `${rects} `;
}
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: `${t('enums.asset_kind.' + k.asset.kind)} `,
html: `` });
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 ? `${esc(m)}
` : '';
if (id !== 'prices') { koersFilter = null; koersFocus = null; }
if (id === 'source') bronPaneel(el, panel, k, kop).catch(e => { panel.innerHTML = kop + `${esc(e.message)}
`; });
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 + `${esc(e.message)}
`; });
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 => ` ${esc(soortTelling(k, counts[k]))} `)
.join('');
const kop = dagen.length
? `${esc(t('priceHistory.cardPeriod', { dagen: telTekst('dayCount', dagen.length) }))} ${telling}
`
: `${esc(t('priceHistory.cardNoPrices'))}
`;
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}${esc(t('priceHistory.provNoWeak'))}
`;
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))
? `${CHEVRON_RIGHT} ` : '');
const rijen = zwak.map(r => `
${esc(r.days === 1 ? r.from : t('priceHistory.provRange', { van: r.from, tot: r.until }))}
${esc(kindLabel(r.kind))}
${r.days}
${bronVan(r)}
${esc(toelichting(r))}
${naarGat(r)}
`).join('');
return `${kop}
${esc(t('priceHistory.provColPeriod'))}
${esc(t('priceHistory.provColKind'))}
${esc(t('priceHistory.provColDays'))}
${esc(t('priceHistory.provColSource'))}
${esc(t('priceHistory.provColNote'))}
${rijen}
`;
}
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
? `${esc(t('priceHistory.diagProposal', { naam: b.proposal.providerName }))}
` : '';
return `${meldingHtml({ severity: b.severity, tekst })}${voorstel}
`;
}).join('');
panel.innerHTML = `${kop}${regels}
`;
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) => `
${esc(t(`health.kind.${kind}`))}
${esc(t('priceHistory.checkShowKind'))}
${esc(t(`health.why.${kind}`))}
${rows.map(({ row, counts }, ri) => `
${esc(dayNl(localDateParts(row.observedAt).date))} · ${esc(bron(row))}
${esc(maat(counts || {}))}
${CHEVRON_RIGHT}
`).join('')}
`).join('') + `${esc(t('priceHistory.checkHint'))}
`;
}
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
? `${esc(koersFilter.label)}
${esc(t('priceHistory.checkClearFilter'))}
`
: '';
panel.innerHTML = kop + filter + '
';
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 = `${esc(t('priceHistory.gapBusy'))}
${esc(t('priceHistory.gapStop'))}
`;
return box.querySelector('[data-stop]');
}
const gatKnop = (attr, i, label, icoon) =>
`${icoon} `;
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 ? `
${esc(t('priceHistory.gapCardSummary', { gaten: telTekst('gapCount', k.gaps.length), dagen: telTekst('dayCount', dagen) }))}
${esc(t('priceHistory.gapIgnoreAll'))}
${esc(t('priceHistory.gapFillAll'))}
` : `${esc(t('priceHistory.gapNone'))}
`;
const lijst = `${k.gaps.map((g, i) => `
${esc(t('priceHistory.gapLine', { van: g.from, tot: g.until, n: g.days }))}
${gatKnop('data-gap', i, t('priceHistory.gapFill'), REFRESH)}${gatKnop('data-gap-ignore', i, t('priceHistory.gapIgnore'), EYE_OFF)}
`).join('')}
`;
const openVoorFocus = k.ignored.some(g => inFocus(g)) && !expand.open('ignored');
const genegeerd = k.ignored.length ? `
${CHEVRON_RIGHT}${esc(t('priceHistory.tabIgnored'))} (${k.ignored.length})
${k.ignored.map(g => `
${esc(t('priceHistory.gapLine', { van: g.fromDay, tot: g.untilDay, n: daysIncl(g.fromDay, g.untilDay) }))}
${gatKnop('data-unignore', g.id, t('priceHistory.gapUnignore'), EYE)}
`).join('')}
` : '';
const afwezig = k.absent.length ? `
${CHEVRON_RIGHT}${esc(t('priceHistory.tabAbsent'))} (${k.absent.length})
${esc(t('priceHistory.absentExplain'))}
${k.absent.map((g, i) => `
${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) }))}
${gatKnop('data-absent-forget', i, t('priceHistory.absentForget'), CLOSE)}
`).join('')}
` : '';
panel.innerHTML = `${kop}
${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);
}