// 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 = ``; } 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)) ? `` : ''); 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}
${rijen}
${esc(t('priceHistory.provColPeriod'))} ${esc(t('priceHistory.provColKind'))} ${esc(t('priceHistory.provColDays'))} ${esc(t('priceHistory.provColSource'))} ${esc(t('priceHistory.provColNote'))}
`; } 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(`health.why.${kind}`))}

${rows.map(({ row, counts }, ri) => `
${esc(dayNl(localDateParts(row.observedAt).date))} · ${esc(bron(row))} ${esc(maat(counts || {}))}
`).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)}
` : ''; 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'))}
`; return box.querySelector('[data-stop]'); } const gatKnop = (attr, i, label, 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.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); }