// VIEW ITEM MODAL — Card-style showcase with coin images + enriched data
// =============================================================================
/**
* Active object URLs created for the current view modal session.
* Revoked on modal close to prevent memory leaks.
* @type {string[]}
*/
let _viewModalObjectUrls = [];
/** @type {Chart|null} Price history chart instance — destroyed on modal close */
let _viewModalChartInstance = null;
/** @type {number[]} Available chart range options (0 = all, -1 = from purchase date) */
const _VIEW_CHART_RANGES = [7, 14, 30, 60, 90, 180, 365, 1825, 3650, -1, 0];
/** @type {string[]} Display labels for chart range pills */
const _VIEW_CHART_RANGE_LABELS = ['7d', '14d', '30d', '60d', '90d', '180d', '1Y', '5Y', '10Y', 'Purchased', 'All'];
/** @type {number} Default chart range in days (-1 = from purchase date, falls back to 30d) */
const _VIEW_CHART_DEFAULT_RANGE = -1;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Open the view modal for a specific inventory item.
* @param {number} index - Index into the global `inventory` array
*/
async function showViewModal(index) {
const item = inventory[index];
if (!item) return;
const modal = document.getElementById('viewItemModal');
if (!modal) return;
const body = document.getElementById('viewModalBody');
if (!body) return;
// Build modal content
body.textContent = '';
body.appendChild(buildViewContent(item, index));
// Render price history chart (canvas must be in DOM first)
const chartCanvas = body.querySelector('#viewPriceHistoryChart');
if (chartCanvas && chartCanvas._chartData) {
const cd = chartCanvas._chartData;
// "Purchased" default (-1): calculate days from purchase date, fall back to 30d
let initRange = _VIEW_CHART_DEFAULT_RANGE;
if (initRange === -1) {
initRange = cd.purchaseDate > 0
? Math.max(1, Math.ceil((Date.now() - cd.purchaseDate) / 86400000))
: 30;
}
if (initRange === 0 || initRange > 180) {
const metalName = item.metal || 'Silver';
_fetchHistoricalSpotData(metalName, initRange).then((fullSpot) => {
_createPriceHistoryChart(chartCanvas, fullSpot, cd.retailEntries, cd.purchasePerUnit, cd.meltFactor, initRange, cd.purchaseDate, cd.currentRetail);
}).catch(() => {
_createPriceHistoryChart(chartCanvas, cd.spotEntries, cd.retailEntries, cd.purchasePerUnit, cd.meltFactor, initRange, cd.purchaseDate, cd.currentRetail);
});
} else {
_createPriceHistoryChart(chartCanvas, cd.spotEntries, cd.retailEntries, cd.purchasePerUnit, cd.meltFactor, initRange, cd.purchaseDate, cd.currentRetail);
}
}
modal.style.display = 'flex';
// Load images and Numista data asynchronously after modal is visible
// Share a single API result to avoid duplicate calls
const catalogId = item.numistaId || '';
let apiResult = null;
// Try loading images from cache/item first
const cacheResult = await loadViewImages(item, body);
const imagesLoaded = cacheResult.loaded;
const imageSource = cacheResult.source;
// Check whether metadata is already cached in IndexedDB
let metaCached = false;
if (catalogId && window.imageCache?.isAvailable()) {
try {
const cachedMeta = await imageCache.getMetadata(catalogId);
metaCached = !!(cachedMeta && (Date.now() - (cachedMeta.cachedAt || 0)) < VIEW_METADATA_TTL);
} catch { /* ignore */ }
}
// Only hit the API when images are missing OR metadata is not in cache
if (catalogId && (!imagesLoaded || !metaCached)) {
apiResult = await _fetchNumistaResult(catalogId);
}
// Fill images from API result when:
// 1. No images were loaded at all, OR
// 2. Override is ON and current images are from user uploads or pattern rules (Numista wins)
const numistaOverride = localStorage.getItem('numistaOverridePersonal') === 'true';
const shouldReplaceWithApi = !imagesLoaded || (numistaOverride && (imageSource === 'pattern' || imageSource === 'user'));
if (shouldReplaceWithApi && apiResult && (apiResult.imageUrl || apiResult.reverseImageUrl)) {
const section = body.querySelector('#viewImageSection');
if (section) {
const slots = section.querySelectorAll('.view-image-slot');
if (apiResult.imageUrl) _setSlotImage(slots[0], apiResult.imageUrl);
if (apiResult.reverseImageUrl) _setSlotImage(slots[1], apiResult.reverseImageUrl);
// Cache for next time (future resolveImageForItem will find it)
if (window.imageCache?.isAvailable()) {
imageCache.cacheImages(catalogId, apiResult.imageUrl || '', apiResult.reverseImageUrl || '').catch(() => {});
}
}
} else if (!imagesLoaded && apiResult) {
// Fallback: cache even if no image URLs (metadata-only result)
if (window.imageCache?.isAvailable() && catalogId) {
imageCache.cacheImages(catalogId, apiResult.imageUrl || '', apiResult.reverseImageUrl || '').catch(() => {});
}
}
// Persist CDN URLs to the inventory item so table/card views can use them
// without needing an API call. Runs regardless of whether images were replaced
// in the modal — the URLs are valuable for card/table rendering. Never overwrite.
if (apiResult && (apiResult.imageUrl || apiResult.reverseImageUrl)) {
let urlsDirty = false;
if (apiResult.imageUrl && !item.obverseImageUrl) {
item.obverseImageUrl = apiResult.imageUrl;
urlsDirty = true;
}
if (apiResult.reverseImageUrl && !item.reverseImageUrl) {
item.reverseImageUrl = apiResult.reverseImageUrl;
urlsDirty = true;
}
if (urlsDirty && typeof saveInventory === 'function') {
saveInventory();
}
}
// Load Numista enrichment section
await loadViewNumistaData(item, body, apiResult);
}
/**
* Close the view modal and clean up resources.
*/
function closeViewModal() {
const modal = document.getElementById('viewItemModal');
if (modal) modal.style.display = 'none';
// Destroy price history chart to free canvas resources
if (_viewModalChartInstance) {
_viewModalChartInstance.destroy();
_viewModalChartInstance = null;
}
// Revoke all object URLs to free memory
_viewModalObjectUrls.forEach(url => {
try { URL.revokeObjectURL(url); } catch { /* ignore */ }
});
_viewModalObjectUrls = [];
}
// ---------------------------------------------------------------------------
// Content builder
// ---------------------------------------------------------------------------
/**
* Compute shared metrics used by all view modal section renderers.
* @param {Object} item - Inventory item
* @returns {Object} Metrics object with currentSpot, qty, weight, purity, isGb, weightOz, metalColor
*/
function _getViewMetrics(item) {
const metalKey = (item.metal || 'silver').toLowerCase();
const currentSpot = spotPrices[metalKey] || 0;
const qty = Number(item.qty) || 1;
const weight = parseFloat(item.weight) || 0;
const purity = parseFloat(item.purity) || 1.0;
const isGb = item.weightUnit === 'gb';
const weightOz = isGb ? weight * GB_TO_OZT : weight;
const metalColor = typeof getMetalColor === 'function' ? getMetalColor(metalKey) : null;
return { currentSpot, qty, weight, purity, isGb, weightOz, metalColor };
}
function _renderHeaderMeta(item, metrics) {
const header = document.getElementById('viewModalTitle');
if (header) header.textContent = sanitizeHtml(item.name || 'Untitled Item');
_renderCatalogBadge(item);
_applyHeaderGradient(header, metrics.metalColor);
_renderCountChip(item);
}
function _renderCatalogBadge(item) {
const catalogBadge = document.getElementById('viewModalCatalogId');
if (!catalogBadge) return;
const nId = item.numistaId || '';
catalogBadge.textContent = nId ? `N#${nId}` : '';
catalogBadge.style.display = nId ? '' : 'none';
if (!nId) {
catalogBadge.onclick = null;
catalogBadge.style.cursor = '';
return;
}
catalogBadge.style.cursor = 'pointer';
catalogBadge.title = 'View on Numista';
catalogBadge.onclick = (e) => {
e.stopPropagation();
const isSet = /^S/i.test(nId);
const cleanId = nId.replace(/^[NS]?#?\s*/i, '').trim();
const url = isSet
? `https://en.numista.com/catalogue/set.php?id=${cleanId}`
: `https://en.numista.com/catalogue/pieces${cleanId}.html`;
_openExternalPopup(url, `numista_${nId}`);
};
}
function _applyHeaderGradient(header, metalColor) {
const modalHeader = document.getElementById('viewItemModal')?.querySelector('.modal-header');
if (!modalHeader || !metalColor) return;
modalHeader.style.background = `linear-gradient(135deg, ${metalColor}, ${_darkenColor(metalColor, 0.3)})`;
const textColor = _isLightColor(metalColor) ? '#1e293b' : '#f8fafc';
modalHeader.style.color = textColor;
if (header) header.style.color = textColor;
}
function _renderCountChip(item) {
const countChip = document.getElementById('viewModalCountChip');
if (!countChip) return;
const totalQty = inventory.reduce((sum, invItem) => {
return invItem.name === item.name && invItem.metal === item.metal
? sum + (Number(invItem.qty) || 1)
: sum;
}, 0);
countChip.textContent = totalQty > 1 ? `\u00d7${totalQty} in inventory` : '';
countChip.style.display = totalQty > 1 ? '' : 'none';
}
function _buildImageSection(item, metrics) {
const itemType = (item.type || '').toLowerCase();
const isRectShape = itemType === 'bar' || itemType === 'note' || itemType === 'aurum'
|| itemType === 'set' || metrics.isGb;
const imgSection = _el('div', 'view-image-section' + (isRectShape ? ' view-shape-rect' : ''));
imgSection.id = 'viewImageSection';
imgSection.appendChild(_imageSlot('obverse', 'Obverse'));
imgSection.appendChild(_imageSlot('reverse', 'Reverse'));
if (metrics.metalColor) {
imgSection.style.background = `linear-gradient(145deg, color-mix(in srgb, ${metrics.metalColor} 15%, #1a1a2e), color-mix(in srgb, ${metrics.metalColor} 8%, #16213e))`;
}
const badge = _buildImageCertBadge(item);
if (badge) imgSection.appendChild(badge);
return imgSection;
}
function _buildImageCertBadge(item) {
if (!item.grade) return null;
const badge = _el('div', 'view-cert-badge');
const authority = item.gradingAuthority || '';
const certNum = item.certNumber || '';
const pcgsNo = item.pcgsNumber || '';
const isVerified = item.pcgsVerified === true && authority === 'PCGS';
if (authority) badge.dataset.authority = authority;
const gradeSpan = _buildImageCertGrade(item, authority, certNum, pcgsNo);
badge.appendChild(gradeSpan);
const verifySpan = _buildPcgsVerifyControl(item, authority, certNum, isVerified, false);
if (verifySpan) badge.appendChild(verifySpan);
return badge;
}
function _buildImageCertGrade(item, authority, certNum, pcgsNo) {
const gradeSpan = _el('span', 'view-cert-grade');
gradeSpan.textContent = authority ? `${authority} ${item.grade}` : item.grade;
const certUrlTemplate = (typeof CERT_LOOKUP_URLS !== 'undefined' && authority) ? CERT_LOOKUP_URLS[authority] : '';
const hasCertLink = certUrlTemplate && (certNum || pcgsNo);
const hasCoinFacts = authority === 'PCGS' && pcgsNo;
if (hasCertLink || hasCoinFacts) {
gradeSpan.classList.add('view-cert-clickable');
gradeSpan.title = certNum ? `Look up ${authority} Cert #${certNum}` : `Open ${authority} verification`;
gradeSpan.tabIndex = 0;
gradeSpan.role = 'button';
gradeSpan.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); gradeSpan.click(); } });
gradeSpan.addEventListener('click', (e) => {
e.stopPropagation();
const url = hasCoinFacts
? _buildPcgsCoinFactsUrl(item.grade || '', pcgsNo)
: certUrlTemplate.replace(/\{certNumber\}/g, encodeURIComponent(certNum)).replace(/\{grade\}/g, encodeURIComponent(item.grade || ''));
const popupName = `cert_${authority}_${certNum || pcgsNo}`.replace(/[^a-zA-Z0-9_]/g, '_');
const popup = window.open(url, popupName, 'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no');
if (popup) popup.focus();
});
} else {
gradeSpan.title = authority ? `Graded by ${authority}: ${item.grade}${certNum ? ` — Cert #${certNum}` : ''}` : `Grade: ${item.grade}`;
}
return gradeSpan;
}
function _buildPcgsCoinFactsUrl(gradeText, pcgsNo) {
const gradeNum = gradeText.match(/\d+/)?.[0] || '';
return gradeNum
? `https://www.pcgs.com/coinfacts/coin/detail/${encodeURIComponent(pcgsNo)}/${encodeURIComponent(gradeNum)}`
: `https://www.pcgs.com/coinfacts/coin/${encodeURIComponent(pcgsNo)}`;
}
function _buildPcgsVerifyControl(item, authority, certNum, isVerified, inline) {
const showVerifyBtn = authority === 'PCGS' && certNum
&& typeof catalogConfig !== 'undefined' && catalogConfig.isPcgsEnabled()
&& typeof verifyPcgsCert === 'function';
if (!showVerifyBtn) return null;
const cls = inline ? 'view-cert-verify view-cert-verify-inline' : 'view-cert-verify';
const verifySpan = _el('span', `${cls}${isVerified ? ' pcgs-verified' : ''}`);
verifySpan.tabIndex = 0;
verifySpan.role = 'button';
verifySpan.dataset.certNumber = certNum;
verifySpan.title = isVerified ? `Verified — Cert #${certNum}` : 'Verify cert via PCGS API';
verifySpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
verifySpan.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); verifySpan.click(); } });
verifySpan.addEventListener('click', (e) => {
e.stopPropagation();
_verifyPcgsCertAndUpdate(item, certNum, verifySpan, inline);
});
return verifySpan;
}
function _verifyPcgsCertAndUpdate(item, certNum, verifyEl, syncImageBadge) {
verifyEl.classList.add('pcgs-verifying');
verifyEl.title = 'Verifying...';
verifyPcgsCert(certNum).then((result) => {
verifyEl.classList.remove('pcgs-verifying');
if (!result.verified) {
verifyEl.title = result.error || 'Verification failed';
verifyEl.classList.add('pcgs-verify-failed');
setTimeout(() => verifyEl.classList.remove('pcgs-verify-failed'), 3000);
return;
}
verifyEl.classList.add('pcgs-verified');
const idx = inventory.findIndex((inv) => inv.uuid === item.uuid);
if (idx >= 0) {
inventory[idx].pcgsVerified = true;
saveInventory();
}
const parts = [];
if (result.grade) parts.push(`Grade: ${result.grade}`);
if (result.population) parts.push(`Pop: ${result.population}`);
if (result.popHigher) parts.push(`Pop Higher: ${result.popHigher}`);
if (result.priceGuide) parts.push(`Price Guide: $${Number(result.priceGuide).toLocaleString()}`);
verifyEl.title = `Verified — ${parts.join(' | ')}`;
if (syncImageBadge) {
const imgBadgeVerify = document.querySelector('#viewItemModal .view-cert-verify:not(.view-cert-verify-inline)');
if (imgBadgeVerify) imgBadgeVerify.classList.add('pcgs-verified');
}
}).catch((err) => {
verifyEl.classList.remove('pcgs-verifying');
verifyEl.title = 'Verification service unavailable';
if (typeof debugLog === 'function') debugLog('warn', 'PCGS verify failed:', err);
});
}
function _buildInventorySection(item, metrics) {
const invSection = _section('Inventory');
const invGrid = _el('div', 'view-detail-grid three-col');
_addDetail(invGrid, 'Metal', item.composition || item.metal || '—');
_addDetail(invGrid, 'Type', item.type || '—');
_addDetail(invGrid, 'Year', item.year || '—');
_addDetail(invGrid, 'Purity', metrics.purity < 1 ? `.${String(metrics.purity).replace('0.', '')}` : metrics.purity === 1 ? '.999+' : String(metrics.purity));
_addDetail(invGrid, 'Weight', typeof formatWeight === 'function' ? formatWeight(metrics.weight, item.weightUnit) : `${metrics.weight} oz`);
_addDetail(invGrid, 'Qty', String(metrics.qty));
invSection.appendChild(invGrid);
const invGrid2 = _el('div', 'view-detail-grid three-col');
const dateVal = item.date ? (typeof formatDisplayDate === 'function' ? formatDisplayDate(item.date) : item.date) : '—';
_addDetail(invGrid2, 'Date', dateVal);
_appendSourceField(invGrid2, item.purchaseLocation || '—');
invSection.appendChild(invGrid2);
const storGrid = _el('div', 'view-detail-grid');
_addDetail(storGrid, 'Storage', item.storageLocation || '\u2014');
invSection.appendChild(storGrid);
return invSection;
}
function _appendSourceField(container, sourceValue) {
const srcUrlPattern = /^(https?:\/\/)?[\w.-]+\.(com|net|org|co|io|us|uk|ca|au|de|fr|shop|store)\b/i;
if (!srcUrlPattern.test(sourceValue)) {
_addDetail(container, 'Source', sourceValue);
return;
}
const srcItem = _detailItem('Source', '');
const valEl = srcItem.querySelector('.view-detail-value');
if (valEl) {
valEl.textContent = '';
const srcLink = document.createElement('a');
srcLink.href = '#';
const srcHref = /^https?:\/\//i.test(sourceValue) ? sourceValue : `https://${sourceValue}`;
srcLink.title = srcHref;
srcLink.style.color = 'var(--primary)';
srcLink.style.textDecoration = 'none';
srcLink.textContent = sourceValue.replace(/^(https?:\/\/)?(www\.)?/i, '').replace(/\/(.*)/i, '');
srcLink.addEventListener('click', (e) => { e.preventDefault(); _openExternalPopup(srcHref, 'source_popup'); });
valEl.appendChild(srcLink);
}
container.appendChild(srcItem);
}
function _buildValuationSection(item, metrics) {
const meltValue = metrics.currentSpot > 0 ? metrics.weightOz * metrics.qty * metrics.currentSpot * metrics.purity : 0;
const purchaseTotal = metrics.qty * (parseFloat(item.price) || 0);
const marketVal = parseFloat(item.marketValue) || 0;
const retailTotal = marketVal > 0 ? metrics.qty * marketVal : meltValue;
const gainLoss = retailTotal > 0 ? retailTotal - purchaseTotal : null;
const valSection = _section('Valuation');
valSection.classList.add('view-valuation-section');
const valGrid = _el('div', 'view-detail-grid four-col');
const purchaseDateStr = item.date ? (typeof formatDisplayDate === 'function' ? formatDisplayDate(item.date) : item.date) : '';
const purchaseLabel = purchaseDateStr ? `${formatCurrency(purchaseTotal)} (${purchaseDateStr})` : formatCurrency(purchaseTotal);
_addDetail(valGrid, 'Purchase', purchaseLabel);
_addDetail(valGrid, 'Melt Value', metrics.currentSpot > 0 ? formatCurrency(meltValue) : '—');
_addDetail(valGrid, 'Retail', retailTotal > 0 ? formatCurrency(retailTotal) : '—');
if (gainLoss !== null && retailTotal > 0) {
const glItem = _detailItem('Gain/Loss', (gainLoss >= 0 ? '+' : '') + formatCurrency(gainLoss));
const valEl = glItem.querySelector('.view-detail-value');
if (valEl) valEl.classList.add(gainLoss >= 0 ? 'gain' : 'loss');
valGrid.appendChild(glItem);
} else {
_addDetail(valGrid, 'Gain/Loss', '—', 'muted');
}
valSection.appendChild(valGrid);
return valSection;
}
function _getPriceHistoryContext(item, metrics) {
const metalName = item.metal || 'Silver';
const meltFactor = metrics.weightOz * metrics.qty * metrics.purity;
const spotEntries = (typeof spotHistory !== 'undefined')
? spotHistory.filter(e => e.metal === metalName).map(e => ({ ts: new Date(e.timestamp).getTime(), spot: e.spot })).sort((a, b) => a.ts - b.ts)
: [];
const spotByDay = new Map();
for (const e of spotEntries) {
const day = new Date(e.ts).toISOString().slice(0, 10);
spotByDay.set(day, e);
}
const dailySpotEntries = [...spotByDay.values()];
const retailEntries = (typeof itemPriceHistory !== 'undefined' && item.uuid) ? (itemPriceHistory[item.uuid] || []).filter(e => e.retail > 0) : [];
return {
metalName,
meltFactor,
dailySpotEntries,
retailEntries,
purchasePerUnit: parseFloat(item.price) || 0,
purchaseDate: item.date ? new Date(item.date).getTime() : 0,
currentRetail: parseFloat(item.marketValue) || 0,
};
}
function _buildPriceHistorySection(chartCtx) {
if (chartCtx.dailySpotEntries.length < 2) return null;
const chartSection = _section('Price History');
const rangeBar = _buildChartRangeBar(chartSection, chartCtx);
chartSection.appendChild(rangeBar);
const chartContainer = _el('div', 'view-chart-container');
const canvas = document.createElement('canvas');
canvas.id = 'viewPriceHistoryChart';
canvas._chartData = {
spotEntries: chartCtx.dailySpotEntries,
retailEntries: chartCtx.retailEntries,
purchasePerUnit: chartCtx.purchasePerUnit,
meltFactor: chartCtx.meltFactor,
purchaseDate: chartCtx.purchaseDate,
currentRetail: chartCtx.currentRetail,
};
chartContainer.appendChild(canvas);
chartSection.appendChild(chartContainer);
return chartSection;
}
function _buildChartRangeBar(chartSection, chartCtx) {
const rangeBar = _el('div', 'view-chart-range-bar');
const dateRange = _buildChartDateRangePicker(rangeBar, chartSection, chartCtx);
_VIEW_CHART_RANGES.forEach((days, i) => {
if (days === -1 && !chartCtx.purchaseDate) return;
const pill = _el('button', 'view-chart-range-pill');
pill.type = 'button';
pill.textContent = _VIEW_CHART_RANGE_LABELS[i];
pill.dataset.days = String(days);
const isDefaultPill = _VIEW_CHART_DEFAULT_RANGE === -1 ? (chartCtx.purchaseDate ? days === -1 : days === 30) : days === _VIEW_CHART_DEFAULT_RANGE;
if (isDefaultPill) pill.classList.add('active');
pill.addEventListener('click', async () => {
rangeBar.querySelectorAll('.view-chart-range-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
await _onChartRangePillClick(days, dateRange, chartSection, chartCtx);
});
rangeBar.appendChild(pill);
});
rangeBar.appendChild(dateRange.wrap);
return rangeBar;
}
function _buildChartDateRangePicker(rangeBar, chartSection, chartCtx) {
const wrap = _el('div', 'view-chart-date-range');
const fromInput = document.createElement('input');
fromInput.type = 'date';
fromInput.className = 'view-chart-date-input';
fromInput.title = 'From date';
const toInput = document.createElement('input');
toInput.type = 'date';
toInput.className = 'view-chart-date-input';
toInput.title = 'To date';
const todayStr = new Date().toISOString().slice(0, 10);
fromInput.max = todayStr;
toInput.max = todayStr;
const dateSep = _el('span', 'view-chart-date-sep');
dateSep.textContent = '\u2014';
wrap.appendChild(fromInput);
wrap.appendChild(dateSep);
wrap.appendChild(toInput);
const onDateChange = async () => {
rangeBar.querySelectorAll('.view-chart-range-pill').forEach(p => p.classList.remove('active'));
if (fromInput.value) toInput.min = fromInput.value; else toInput.min = '';
if (toInput.value) fromInput.max = toInput.value; else fromInput.max = todayStr;
const fromTs = fromInput.value ? new Date(fromInput.value + 'T00:00:00').getTime() : 0;
const toTs = toInput.value ? new Date(toInput.value + 'T23:59:59').getTime() : 0;
if (fromTs <= 0 && toTs <= 0) return;
const canvas = chartSection.querySelector('#viewPriceHistoryChart');
if (!canvas) return;
try {
const fullSpot = await _fetchHistoricalSpotData(chartCtx.metalName, 0, fromTs, toTs);
_createPriceHistoryChart(canvas, fullSpot, chartCtx.retailEntries, chartCtx.purchasePerUnit, chartCtx.meltFactor, 0, chartCtx.purchaseDate, chartCtx.currentRetail, fromTs, toTs);
} catch (err) {
console.error('Custom date range fetch failed:', err);
_createPriceHistoryChart(canvas, [], chartCtx.retailEntries, chartCtx.purchasePerUnit, chartCtx.meltFactor, 0, chartCtx.purchaseDate, chartCtx.currentRetail, fromTs, toTs);
}
};
fromInput.addEventListener('change', onDateChange);
toInput.addEventListener('change', onDateChange);
return { wrap, fromInput, toInput, todayStr };
}
async function _onChartRangePillClick(days, dateRange, chartSection, chartCtx) {
dateRange.fromInput.value = '';
dateRange.toInput.value = '';
dateRange.fromInput.max = dateRange.todayStr;
dateRange.toInput.min = '';
const canvas = chartSection.querySelector('#viewPriceHistoryChart');
if (!canvas) return;
const effectiveDays = days === -1 && chartCtx.purchaseDate > 0
? Math.max(1, Math.ceil((Date.now() - chartCtx.purchaseDate) / 86400000))
: days;
if (effectiveDays === 0 || effectiveDays > 180) {
try {
const fullSpot = await _fetchHistoricalSpotData(chartCtx.metalName, effectiveDays);
_createPriceHistoryChart(canvas, fullSpot, chartCtx.retailEntries, chartCtx.purchasePerUnit, chartCtx.meltFactor, effectiveDays, chartCtx.purchaseDate, chartCtx.currentRetail);
} catch (err) {
console.error('Range pill fetch failed:', err);
_createPriceHistoryChart(canvas, chartCtx.dailySpotEntries, chartCtx.retailEntries, chartCtx.purchasePerUnit, chartCtx.meltFactor, effectiveDays, chartCtx.purchaseDate, chartCtx.currentRetail);
}
return;
}
_createPriceHistoryChart(canvas, chartCtx.dailySpotEntries, chartCtx.retailEntries, chartCtx.purchasePerUnit, chartCtx.meltFactor, effectiveDays, chartCtx.purchaseDate, chartCtx.currentRetail);
}
function _buildGradingSection(item) {
if (!item.grade && !item.gradingAuthority && !item.certNumber) return null;
const gradeSection = _section('Grading');
const gradeGrid = _el('div', 'view-detail-grid three-col');
_addDetail(gradeGrid, 'Grade', item.grade || '—');
_addDetail(gradeGrid, 'Authority', item.gradingAuthority || '—');
const certItem = _buildGradingCertItem(item);
if (certItem) gradeGrid.appendChild(certItem); else _addDetail(gradeGrid, 'Cert #', '—');
gradeSection.appendChild(gradeGrid);
return gradeSection;
}
function _buildGradingCertItem(item) {
if (!item.certNumber) return null;
const certItem = _detailItem('Cert #', item.certNumber);
_attachGradingCertLink(certItem, item);
const valEl = certItem.querySelector('.view-detail-value');
if (!valEl) return certItem;
const inlineVerify = _buildPcgsVerifyControl(item, item.gradingAuthority || '', item.certNumber, item.pcgsVerified === true, true);
if (inlineVerify) valEl.appendChild(inlineVerify);
return certItem;
}
function _attachGradingCertLink(certItem, item) {
if (!item.gradingAuthority || typeof CERT_LOOKUP_URLS === 'undefined' || !CERT_LOOKUP_URLS[item.gradingAuthority]) return;
const url = CERT_LOOKUP_URLS[item.gradingAuthority]
.replace(/{certNumber}/g, encodeURIComponent(item.certNumber))
.replace(/{grade}/g, encodeURIComponent(item.grade || ''));
const valEl = certItem.querySelector('.view-detail-value');
if (!valEl) return;
valEl.textContent = '';
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = item.certNumber;
link.style.color = 'var(--primary)';
link.title = `Verify on ${item.gradingAuthority}`;
valEl.appendChild(link);
}
function _buildNumistaPlaceholderSection() {
const numistaPlaceholder = _el('div', '');
numistaPlaceholder.id = 'viewNumistaSection';
return numistaPlaceholder;
}
function _buildTagsSection(item) {
if (typeof buildTagSection !== 'function') return null;
return buildTagSection(item.uuid, [], () => {
if (typeof renderActiveFilters === 'function') renderActiveFilters();
});
}
function _buildNotesSection(item) {
if (!item.notes) return null;
const notesSection = _section('Notes');
const noteText = _el('div', 'view-notes-text');
noteText.textContent = item.notes;
notesSection.appendChild(noteText);
return notesSection;
}
function _appendSectionsInConfiguredOrder(frag, sectionBuilders) {
const sectionConfig = typeof getViewModalSectionConfig === 'function' ? getViewModalSectionConfig() : VIEW_MODAL_SECTION_DEFAULTS;
for (const sec of sectionConfig) {
if (!sec.enabled) continue;
const builder = sectionBuilders[sec.id];
if (!builder) continue;
const el = builder();
if (el) frag.appendChild(el);
}
}
function _renderHeaderActions(item, index) {
const headerActions = document.getElementById('viewHeaderActions');
if (!headerActions) return;
headerActions.textContent = '';
const ebayBtn = document.createElement('button');
ebayBtn.className = 'view-ebay-btn';
ebayBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" style="fill:currentColor;margin-right:4px;vertical-align:-2px;"><circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="2.5"/><line x1="15" y1="15" x2="21" y2="21" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg>eBay';
ebayBtn.title = 'Search eBay for this item';
ebayBtn.addEventListener('click', () => {
const searchTerm = (item.metal || '') + (item.year ? ' ' + item.year : '') + ' ' + (item.name || '');
if (typeof openEbayBuySearch === 'function') openEbayBuySearch(searchTerm);
else if (typeof openEbaySoldSearch === 'function') openEbaySoldSearch(searchTerm);
});
const editBtn = document.createElement('button');
editBtn.className = 'view-edit-btn';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => {
closeViewModal();
if (typeof editItem === 'function') editItem(index);
});
const closeBtn = document.createElement('button');
closeBtn.className = 'view-close-btn';
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', closeViewModal);
headerActions.appendChild(ebayBtn);
headerActions.appendChild(editBtn);
headerActions.appendChild(closeBtn);
}
/**
* Build the full view modal body as a DocumentFragment.
* Sections are built eagerly then appended in user-configured order.
* @param {Object} item - Inventory item
* @param {number} index - Item index for edit button
* @returns {DocumentFragment}
*/
function buildViewContent(item, index) {
const frag = document.createDocumentFragment();
const metrics = _getViewMetrics(item);
_renderHeaderMeta(item, metrics);
const chartCtx = _getPriceHistoryContext(item, metrics);
const sectionBuilders = {
images: () => _buildImageSection(item, metrics),
priceHistory: () => _buildPriceHistorySection(chartCtx),
valuation: () => _buildValuationSection(item, metrics),
inventory: () => _buildInventorySection(item, metrics),
grading: () => _buildGradingSection(item),
numista: () => _buildNumistaPlaceholderSection(),
tags: () => _buildTagsSection(item),
notes: () => _buildNotesSection(item),
};
_appendSectionsInConfiguredOrder(frag, sectionBuilders);
_renderHeaderActions(item, index);
return frag;
}
// ---------------------------------------------------------------------------
// Async loaders
// ---------------------------------------------------------------------------
/**
* Load coin images from IndexedDB cache → CDN URL fallback.
* @param {Object} item
* @param {HTMLElement} container
* @returns {Promise<{loaded: boolean, source: string|null}>}
*/
async function loadViewImages(item, container) {
const section = container.querySelector('#viewImageSection');
if (!section) return { loaded: false, source: null };
const slots = section.querySelectorAll('.view-image-slot');
const obvSlot = slots[0];
const revSlot = slots[1];
if (!window.imageCache?.isAvailable()) {
// Fallback: CDN URLs stored on the item
const validObv = ImageCache.isValidImageUrl(item.obverseImageUrl);
const validRev = ImageCache.isValidImageUrl(item.reverseImageUrl);
if (validObv) _setSlotImage(obvSlot, item.obverseImageUrl);
if (validRev) _setSlotImage(revSlot, item.reverseImageUrl);
return { loaded: validObv || validRev, source: 'cdn' };
}
// Use the resolution cascade (user → pattern → numista)
const resolved = await imageCache.resolveImageForItem(item);
if (resolved) {
let obvUrl, revUrl;
if (resolved.source === 'user') {
obvUrl = await imageCache.getUserImageUrl(resolved.catalogId, 'obverse');
revUrl = await imageCache.getUserImageUrl(resolved.catalogId, 'reverse');
} else if (resolved.source === 'pattern') {
obvUrl = await imageCache.getPatternImageUrl(resolved.catalogId, 'obverse');
revUrl = await imageCache.getPatternImageUrl(resolved.catalogId, 'reverse');
} else {
obvUrl = await imageCache.getImageUrl(resolved.catalogId, 'obverse');
revUrl = await imageCache.getImageUrl(resolved.catalogId, 'reverse');
}
if (obvUrl) { _viewModalObjectUrls.push(obvUrl); _setSlotImage(obvSlot, obvUrl); }
if (revUrl) { _viewModalObjectUrls.push(revUrl); _setSlotImage(revSlot, revUrl); }
if (obvUrl || revUrl) return { loaded: true, source: resolved.source };
}
// Final fallback: CDN URLs stored on the item (validate to skip corrupted URLs)
const validObv = ImageCache.isValidImageUrl(item.obverseImageUrl);
const validRev = ImageCache.isValidImageUrl(item.reverseImageUrl);
if (validObv) _setSlotImage(obvSlot, item.obverseImageUrl);
if (validRev) _setSlotImage(revSlot, item.reverseImageUrl);
return { loaded: validObv || validRev, source: (validObv || validRev) ? 'cdn' : null };
}
/**
* Load Numista metadata from IndexedDB cache or pre-fetched API result, render enrichment section.
* @param {Object} item
* @param {HTMLElement} container
* @param {Object|null} apiResult - Pre-fetched Numista API result (avoids duplicate call)
*/
async function loadViewNumistaData(item, container, apiResult) {
const catalogId = item.numistaId || '';
if (!catalogId) return;
const placeholder = container.querySelector('#viewNumistaSection');
if (!placeholder) return;
let meta = null;
// Check cache
if (window.imageCache?.isAvailable()) {
meta = await imageCache.getMetadata(catalogId);
// Stale check
if (meta && (Date.now() - (meta.cachedAt || 0)) > VIEW_METADATA_TTL) {
meta = null; // Force refresh
}
}
// Use pre-fetched API result if no cache hit
if (!meta && apiResult) {
meta = _extractMetadata(apiResult);
// Cache for next time
if (window.imageCache?.isAvailable()) {
imageCache.cacheMetadata(catalogId, apiResult).catch(() => {});
}
}
if (!meta) return;
// Load user's field visibility config
const cfg = typeof getNumistaViewFieldConfig === 'function'
? getNumistaViewFieldConfig()
: {};
// Update image frame shape based on Numista data if not already rectangular
if (meta.shape) {
const imgSection = container.querySelector('#viewImageSection');
const shapeStr = meta.shape.toLowerCase();
const isNonRound = shapeStr !== 'round' && shapeStr !== 'circular';
if (isNonRound && imgSection && !imgSection.classList.contains('view-shape-rect')) {
imgSection.classList.add('view-shape-rect');
}
}
// Build Numista section
const section = _el('div', 'view-numista-section');
const badge = _el('span', 'view-numista-badge');
badge.textContent = 'Numista Data';
section.appendChild(badge);
const grid = _el('div', 'view-detail-grid');
if (cfg.denomination !== false && meta.denomination) _addDetail(grid, 'Denomination', meta.denomination);
if (cfg.shape !== false && meta.shape) _addDetail(grid, 'Shape', meta.shape);
if (cfg.diameter !== false && meta.diameter) _addDetail(grid, 'Diameter', `${meta.diameter}mm`);
if (cfg.thickness !== false && meta.thickness) _addDetail(grid, 'Thickness', `${meta.thickness}mm`);
if (cfg.orientation !== false && meta.orientation) _addDetail(grid, 'Orientation', meta.orientation);
if (cfg.composition !== false && meta.composition) _addDetail(grid, 'Composition', meta.composition);
if (cfg.country !== false && meta.country) _addDetail(grid, 'Country', meta.country);
if (cfg.technique !== false && meta.technique) _addDetail(grid, 'Technique', meta.technique);
if (cfg.references !== false && meta.kmReferences && meta.kmReferences.length > 0) {
_addDetail(grid, 'References', meta.kmReferences.join(', '));
}
section.appendChild(grid);
// Edge description on its own full-width line (can be long)
if (cfg.edge !== false && meta.edgeDesc) {
const edgeGrid = _el('div', 'view-detail-grid');
const edgeItem = _detailItem('Edge', meta.edgeDesc);
edgeItem.classList.add('full-width');
edgeGrid.appendChild(edgeItem);
section.appendChild(edgeGrid);
}
// Set obverse/reverse descriptions as tooltips on the image slots
if (cfg.imageTooltips !== false && (meta.obverseDesc || meta.reverseDesc)) {
const imgSection = container.querySelector('#viewImageSection');
if (imgSection) {
const slots = imgSection.querySelectorAll('.view-image-slot');
if (meta.obverseDesc && slots[0]) {
slots[0].title = `Obverse: ${meta.obverseDesc}`;
}
if (meta.reverseDesc && slots[1]) {
slots[1].title = `Reverse: ${meta.reverseDesc}`;
}
}
}
// Tags
if (cfg.tags !== false && meta.tags && meta.tags.length > 0) {
const tagGrid = _el('div', 'view-detail-grid');
const tagItem = _detailItem('Tags', meta.tags.join(', '));
tagItem.classList.add('full-width');
tagGrid.appendChild(tagItem);
section.appendChild(tagGrid);
}
// Commemorative
if (cfg.commemorative !== false && meta.commemorative && meta.commemorativeDesc) {
const commGrid = _el('div', 'view-detail-grid');
const commItem = _detailItem('Commemorative', meta.commemorativeDesc);
commItem.classList.add('full-width');
commGrid.appendChild(commItem);
section.appendChild(commGrid);
}
// Rarity index
if (cfg.rarity !== false && meta.rarityIndex > 0) {
const rarityRow = _el('div', 'view-detail-item');
const lbl = _el('span', 'view-detail-label');
lbl.textContent = 'Rarity';
rarityRow.appendChild(lbl);
const bar = _el('div', 'view-rarity-bar');
const track = _el('div', 'view-rarity-track');
const fill = _el('div', 'view-rarity-fill');
fill.style.width = `${Math.min(meta.rarityIndex, 100)}%`;
track.appendChild(fill);
bar.appendChild(track);
const score = _el('span', 'view-rarity-score');
score.textContent = String(meta.rarityIndex);
bar.appendChild(score);
rarityRow.appendChild(bar);
section.appendChild(rarityRow);
}
// Mintage by year (show first few)
if (cfg.mintage !== false && meta.mintageByYear && meta.mintageByYear.length > 0) {
const mintGrid = _el('div', 'view-detail-grid');
const mintItem = _el('div', 'view-detail-item full-width');
const mintLabel = _el('span', 'view-detail-label');
mintLabel.textContent = 'Mintage';
mintItem.appendChild(mintLabel);
const mintVal = _el('span', 'view-detail-value');
const entries = meta.mintageByYear.slice(0, 5);
mintVal.textContent = entries.map(e => {
const m = typeof e.mintage === 'number' ? e.mintage.toLocaleString() : e.mintage;
return `${e.year}: ${m}${e.remark ? ` (${e.remark})` : ''}`;
}).join(' | ');
if (meta.mintageByYear.length > 5) mintVal.textContent += ' ...';
mintItem.appendChild(mintVal);
mintGrid.appendChild(mintItem);
section.appendChild(mintGrid);
}
placeholder.replaceWith(section);
// STAK-126: Auto-apply Numista tags to the item's tag store and refresh
// the tags section so it shows Numista tags with proper visual distinction
if (meta.tags && meta.tags.length > 0 && item.uuid && typeof applyNumistaTags === 'function') {
applyNumistaTags(item.uuid, meta.tags);
// Rebuild the tags section in the modal with Numista tag info
const tagsSectionEl = container.querySelector('#viewTagsSection');
if (tagsSectionEl && typeof buildTagSection === 'function') {
const newTagsSection = buildTagSection(item.uuid, meta.tags, () => {
if (typeof renderActiveFilters === 'function') renderActiveFilters();
});
tagsSectionEl.replaceWith(newTagsSection);
}
}
}
// ---------------------------------------------------------------------------
// API helpers (private)
// ---------------------------------------------------------------------------
/**
* Fetch a Numista item by catalogId. Returns the normalized result or null.
* @param {string} catalogId
* @returns {Promise<Object|null>}
*/
async function _fetchNumistaResult(catalogId) {
if (!catalogId || typeof catalogAPI === 'undefined') return null;
try {
return await catalogAPI.lookupItem(catalogId);
} catch {
return null;
}
}
/**
* Extract metadata fields from a Numista API result.
* @param {Object} result
* @returns {Object}
*/
function _extractMetadata(result) {
return {
title: result.name || '',
country: result.country || '',
denomination: result.denomination || '',
diameter: result.diameter || result.size || 0,
thickness: result.thickness || 0,
weight: result.weight || 0,
shape: result.shape || '',
composition: result.composition || result.metal || '',
orientation: result.orientation || '',
commemorative: !!result.commemorative,
commemorativeDesc: result.commemorativeDesc || '',
rarityIndex: result.rarityIndex || 0,
kmReferences: result.kmReferences || [],
mintageByYear: result.mintageByYear || [],
technique: result.technique || '',
tags: result.tags || [],
obverseDesc: result.obverseDesc || '',
reverseDesc: result.reverseDesc || '',
edgeDesc: result.edgeDesc || '',
};
}
// ---------------------------------------------------------------------------
// External popup (private)
// ---------------------------------------------------------------------------
/**
* Open a URL in a 1250px popup window.
* Most external sites block iframe embedding (X-Frame-Options), so we use window.open().
* @param {string} url
* @param {string} [name='_blank'] - Window name for reuse
*/
function _openExternalPopup(url, name) {
const popup = window.open(
url,
name || '_blank',
'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no'
);
if (!popup) {
// Popup blocked — let user know
alert(`Popup blocked! Please allow popups or manually visit:\n${url}`);
} else {
popup.focus();
}
}
// ---------------------------------------------------------------------------
// Color helpers (private)
// ---------------------------------------------------------------------------
/**
* Parse a color string (hex #rrggbb or rgb(r,g,b)) into [r, g, b].
* @param {string} color
* @returns {number[]} [r, g, b] in 0-255
*/
function _parseColor(color) {
if (!color) return [99, 102, 241]; // fallback indigo
const s = color.trim();
// Handle #rrggbb / #rgb
if (s.startsWith('#')) {
let hex = s.slice(1);
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)];
}
// Handle rgb(r, g, b)
const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (m) return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
return [99, 102, 241];
}
/**
* Darken a hex/rgb color by a factor (0–1). 0 = no change, 1 = black.
* @param {string} color - Hex or rgb() string
* @param {number} amount - Darkening factor
* @returns {string} Hex color
*/
function _darkenColor(color, amount) {
const [r, g, b] = _parseColor(color);
const f = 1 - Math.min(Math.max(amount, 0), 1);
const toHex = v => Math.round(v * f).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Check if a color is light based on relative luminance.
* @param {string} color - Hex or rgb() string
* @returns {boolean} True if light (needs dark text)
*/
function _isLightColor(color) {
const [r, g, b] = _parseColor(color);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5;
}
// ---------------------------------------------------------------------------
// Historical spot data fetcher (private, self-contained)
// ---------------------------------------------------------------------------
/** @type {Map<number, Array>} Year-file cache shared with spot.js when available */
const _viewYearCache = new Map();
/** @type {Map<number, Promise<Array>>} In-flight fetch promises to deduplicate concurrent requests */
const _viewYearFetchPromises = new Map();
/**
* Fetch a single year file with three-tier fallback (fetch → XHR → remote).
* Reuses spot.js cache/fetcher when available; falls back to own implementation.
* Deduplicates concurrent fetches for the same year.
* @param {number} year
* @returns {Promise<Array>}
*/
function _fetchYearFile(year) {
// Prefer spot.js fetcher (shares its dedup + cache)
if (typeof window.fetchYearFile === 'function') {
return window.fetchYearFile(year);
}
// Self-contained fallback
// Already cached — return immediately
if (_viewYearCache.has(year)) return Promise.resolve(_viewYearCache.get(year));
// Already in-flight — return shared promise
if (_viewYearFetchPromises.has(year)) {
return _viewYearFetchPromises.get(year);
}
const filename = `spot-history-${year}.json`;
const localUrl = `data/${filename}`;
const remoteUrl = `https://staktrakr.com/data/${filename}`;
const promise = fetch(localUrl)
.then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); })
.catch(() => new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', localUrl, true);
xhr.responseType = 'json';
xhr.onload = () => (xhr.status === 200 || (xhr.status === 0 && xhr.response)) ? resolve(xhr.response) : reject(new Error(`XHR ${xhr.status}`));
xhr.onerror = () => reject(new Error('XHR error'));
xhr.send();
}))
.catch(() => fetch(remoteUrl).then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }))
.then(entries => {
const valid = Array.isArray(entries) ? entries.filter(e => e && typeof e.spot === 'number' && e.metal && e.timestamp) : [];
_viewYearCache.set(year, valid);
return valid;
})
.catch(() => { _viewYearCache.set(year, []); return []; })
.finally(() => {
_viewYearFetchPromises.delete(year);
});
// Store promise in Map immediately to ensure proper cleanup
_viewYearFetchPromises.set(year, promise);
return promise;
}
/**
* Fetch full historical spot data for a metal by loading year files.
* Merges fetched year-file data with live spotHistory, deduplicates by day
* (live data wins over seed). Returns sorted {ts, spot} entries.
*
* For ranges <= 180 days, just returns the in-memory spotHistory slice (no fetch).
* For longer ranges (including "All"), async-fetches year files back to 1968.
*
* @param {string} metalName - Metal name ('Silver', 'Gold', etc.)
* @param {number} days - Number of days (0 = all available data)
* @param {number} [fromTs=0] - Custom range start (0 = unbounded)
* @param {number} [toTs=0] - Custom range end (0 = unbounded)
* @returns {Promise<Array<{ts:number, spot:number}>>} Sorted daily spot entries
*/
async function _fetchHistoricalSpotData(metalName, days, fromTs, toTs) {
fromTs = fromTs || 0;
toTs = toTs || 0;
// Calculate which years to fetch
let startYear;
if (fromTs > 0) {
startYear = new Date(fromTs).getFullYear();
} else if (days > 0 && days <= 180) {
// Short range — in-memory spotHistory is sufficient
const liveEntries = (typeof spotHistory !== 'undefined' ? spotHistory : [])
.filter(e => e.metal === metalName)
.map(e => ({ ts: new Date(e.timestamp).getTime(), spot: e.spot }));
liveEntries.sort((a, b) => a.ts - b.ts);
const byDay = new Map();
for (const e of liveEntries) byDay.set(new Date(e.ts).toISOString().slice(0, 10), e);
return [...byDay.values()].sort((a, b) => a.ts - b.ts);
} else {
// "All" — go back to 1968 (earliest seed data)
startYear = 1968;
}
const endYear = new Date().getFullYear();
const years = [];
for (let y = startYear; y <= endYear; y++) years.push(y);
// Fetch all needed year files in parallel
const yearArrays = await Promise.all(years.map(_fetchYearFile));
const allHistorical = yearArrays.flat();
// Merge historical + live spotHistory
const live = typeof spotHistory !== 'undefined' ? spotHistory : [];
const combined = [...allHistorical, ...live]
.filter(e => e.metal === metalName)
.map(e => ({ ts: new Date(e.timestamp).getTime(), spot: e.spot }));
// Sort chronologically
combined.sort((a, b) => a.ts - b.ts);
// Dedup to one entry per day (later entries win — live data appended after seed)
const byDay = new Map();
for (const e of combined) {
byDay.set(new Date(e.ts).toISOString().slice(0, 10), e);
}
return [...byDay.values()].sort((a, b) => a.ts - b.ts);
}
// ---------------------------------------------------------------------------
// Price history chart (private)
// ---------------------------------------------------------------------------
/**
* Create a Chart.js line chart showing price history for the viewed item.
* Primary: melt value derived from spotHistory (dense daily data).
* Secondary: retail value anchored from purchase date/price to current market value,
* with sparse itemPriceHistory snapshots in between.
* Purchase price shown as a flat dashed reference line.
*
* @param {HTMLCanvasElement} canvas
* @param {Array<{ts:number, spot:number}>} allSpotEntries - Daily spot prices for this metal
* @param {Array<{ts:number, retail:number}>} allRetailEntries - Sparse retail value snapshots
* @param {number} purchasePerUnit - Original purchase price per unit
* @param {number} meltFactor - weightOz * qty * purity (melt = spot * meltFactor)
* @param {number} [days=0] - Number of days to show (0 = all)
* @param {number} [purchaseDate=0] - Purchase date timestamp (anchor start for retail line)
* @param {number} [currentRetail=0] - Current market/retail value (anchor end for retail line)
* @param {number} [fromTs=0] - Custom range start timestamp (0 = unbounded)
* @param {number} [toTs=0] - Custom range end timestamp (0 = unbounded)
*/
function _createPriceHistoryChart(canvas, allSpotEntries, allRetailEntries, purchasePerUnit, meltFactor, days, purchaseDate, currentRetail, fromTs, toTs) {
if (typeof Chart === 'undefined') return;
// Destroy any previous instance
if (_viewModalChartInstance) {
_viewModalChartInstance.destroy();
_viewModalChartInstance = null;
}
// Filter spot entries by time range
fromTs = fromTs || 0;
toTs = toTs || 0;
const cutoff = days > 0 ? Date.now() - (days * 86400000) : 0;
let spotEntries;
if (fromTs > 0 || toTs > 0) {
// Custom date range mode
spotEntries = allSpotEntries.filter(e =>
(fromTs <= 0 || e.ts >= fromTs) && (toTs <= 0 || e.ts <= toTs)
);
} else {
spotEntries = cutoff > 0 ? allSpotEntries.filter(e => e.ts >= cutoff) : [...allSpotEntries];
}
// If "All" range or custom range and purchase date is before earliest spot data,
// prepend a synthetic entry so the chart extends back to purchase date
const isAllOrCustom = days === 0 || fromTs > 0 || toTs > 0;
if (isAllOrCustom && purchaseDate > 0 && spotEntries.length > 0 && purchaseDate < spotEntries[0].ts) {
spotEntries.unshift({ ts: purchaseDate, spot: spotEntries[0].spot });
}
// Show fallback message if insufficient data for selected range
const container = canvas.parentElement;
const existingMsg = container.querySelector('.view-chart-no-data');
if (existingMsg) existingMsg.remove();
canvas.style.display = '';
if (spotEntries.length < 2) {
canvas.style.display = 'none';
const msg = _el('div', 'view-chart-no-data');
msg.textContent = 'Not enough data for this range';
container.appendChild(msg);
return;
}
// Build labels + melt data from spot entries
// Adaptive formatting: decade spans → year only, multi-year → two-line [month, year],
// single-year → month + day
const firstYear = new Date(spotEntries[0].ts).getFullYear();
const lastYear = new Date(spotEntries[spotEntries.length - 1].ts).getFullYear();
const yearSpan = lastYear - firstYear;
const labels = spotEntries.map(e => {
const d = new Date(e.ts);
if (yearSpan > 10) {
// Decade+ ranges: compact "Jan '24" or just "'24"
return d.toLocaleDateString(undefined, { year: '2-digit', month: 'short' });
}
if (yearSpan >= 1) {
// 1–10 year ranges: two-line label [month day, year]
return [
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
String(d.getFullYear())
];
}
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
});
const meltData = spotEntries.map(e => parseFloat((e.spot * meltFactor).toFixed(2)));
const purchaseLine = spotEntries.map(() => purchasePerUnit);
// Build retail data: anchored from purchase date to present, with sparse midpoints.
// Uses index-based snapping to find the nearest spot entry for each retail point,
// since anchor dates may not have an exact-match spot entry on that calendar day.
const retailData = new Array(spotEntries.length).fill(null);
// Helper: find the index of the spot entry nearest to a given timestamp
const _nearestSpotIdx = (ts) => {
let best = 0;
let bestDist = Math.abs(spotEntries[0].ts - ts);
for (let i = 1; i < spotEntries.length; i++) {
const dist = Math.abs(spotEntries[i].ts - ts);
if (dist < bestDist) { best = i; bestDist = dist; }
}
return best;
};
// Anchor start: purchase price at the leftmost chart position.
// If purchase date is within the visible range, snap to that day.
// If purchase date is before the range, pin to index 0 so the
// retail line always starts with "what you paid" as a reference.
if (purchaseDate > 0) {
if (purchaseDate >= spotEntries[0].ts &&
purchaseDate <= spotEntries[spotEntries.length - 1].ts) {
const idx = _nearestSpotIdx(purchaseDate);
retailData[idx] = purchasePerUnit;
} else if (purchaseDate < spotEntries[0].ts) {
retailData[0] = purchasePerUnit;
}
}
// Middle: sparse itemPriceHistory retail values snapped to nearest spot day
for (const re of allRetailEntries) {
if (cutoff > 0 && re.ts < cutoff) continue;
if (fromTs > 0 && re.ts < fromTs) continue;
if (toTs > 0 && re.ts > toTs) continue;
const idx = _nearestSpotIdx(re.ts);
retailData[idx] = re.retail;
}
// Anchor end: current market value on the last spot entry (≈ today)
if (currentRetail > 0) {
retailData[spotEntries.length - 1] = currentRetail;
}
const hasRetail = retailData.some(v => v !== null);
const showPoints = spotEntries.length <= 30;
const textColor = typeof getChartTextColor === 'function' ? getChartTextColor() : '#1e293b';
const bgColor = typeof getChartBackgroundColor === 'function' ? getChartBackgroundColor() : '#f8fafc';
// Dataset order: purchase (bottom) → melt (middle) → retail (top)
// Layered fills create visual bands showing cost basis, intrinsic value, and market premium
const datasets = [
{
label: 'Purchase Price',
data: purchaseLine,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.06)',
fill: 'origin',
borderDash: [6, 3],
tension: 0,
pointRadius: 0,
pointHoverRadius: 0,
borderWidth: 1.5,
order: 3,
},
{
label: 'Melt Value',
data: meltData,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.12)',
fill: 'origin',
tension: 0.3,
pointRadius: showPoints ? 3 : 0,
pointHoverRadius: 5,
borderWidth: 2,
order: 2,
},
{
label: 'Retail Value',
data: retailData,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
fill: 'origin',
tension: 0.3,
spanGaps: true,
pointRadius: 4,
pointHoverRadius: 6,
borderWidth: 2,
hidden: !hasRetail,
order: 1,
},
];
_viewModalChartInstance = new Chart(canvas, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 400 },
interaction: { mode: 'index', intersect: false },
scales: {
x: {
ticks: {
color: textColor,
maxTicksLimit: 6,
autoSkip: true,
font: { size: 10 }
},
grid: { display: false }
},
y: {
ticks: {
color: textColor,
font: { size: 10 },
callback: function(value) {
return typeof formatCurrency === 'function' ? formatCurrency(value) : '$' + value;
}
},
grid: { color: 'rgba(128,128,128,0.1)' }
}
},
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
usePointStyle: true,
pointStyle: 'line',
padding: 12,
font: { size: 10 }
}
},
tooltip: {
backgroundColor: bgColor,
titleColor: textColor,
bodyColor: textColor,
borderColor: textColor,
borderWidth: 1,
callbacks: {
label: function(ctx) {
if (ctx.parsed.y === null) return null;
const val = typeof formatCurrency === 'function' ? formatCurrency(ctx.parsed.y) : '$' + ctx.parsed.y;
return `${ctx.dataset.label}: ${val}`;
}
}
}
}
}
});
}
// ---------------------------------------------------------------------------
// DOM helpers (private)
// ---------------------------------------------------------------------------
/** Create element with className */
function _el(tag, className) {
const el = document.createElement(tag);
if (className) el.className = className;
return el;
}
/** Create a data section with title */
function _section(title) {
const section = _el('div', 'view-detail-section');
const h = _el('div', 'view-section-title');
h.textContent = title;
section.appendChild(h);
return section;
}
/** Create a label/value detail item element */
function _detailItem(label, value, extraClass) {
const item = _el('div', 'view-detail-item');
const lbl = _el('span', 'view-detail-label');
lbl.textContent = label;
const val = _el('span', 'view-detail-value' + (extraClass ? ' ' + extraClass : ''));
val.textContent = value;
item.appendChild(lbl);
item.appendChild(val);
return item;
}
/** Add a detail item to a grid */
function _addDetail(grid, label, value, extraClass) {
grid.appendChild(_detailItem(label, value, extraClass));
}
/** Create an image slot with placeholder */
function _imageSlot(side, label) {
const slot = _el('div', 'view-image-slot');
slot.dataset.side = side;
const ph = _el('div', 'view-image-placeholder');
ph.textContent = '\uD83E\uDE99'; // coin emoji
slot.appendChild(ph);
const lbl = _el('span', 'view-image-label');
lbl.textContent = label;
slot.appendChild(lbl);
return slot;
}
/** Replace placeholder with actual image in a slot */
function _setSlotImage(slot, src) {
if (!slot || !src) return;
// If an image already exists, update its src (for override replacement)
const existing = slot.querySelector('img');
if (existing) {
existing.src = src;
existing.style.display = '';
return;
}
// First time: replace placeholder with new img element
const ph = slot.querySelector('.view-image-placeholder');
if (!ph) return;
const img = document.createElement('img');
img.src = src;
img.alt = slot.dataset.side || 'Coin';
// Only use lazy loading for network URLs — blob URLs are already in memory
// and lazy loading can prevent display in modals that just became visible
if (!src.startsWith('blob:')) img.loading = 'lazy';
img.onerror = () => { img.style.display = 'none'; };
ph.replaceWith(img);
}
// ---------------------------------------------------------------------------
// Global exposure
// ---------------------------------------------------------------------------
// ESC key handler
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('viewItemModal');
if (modal && modal.style.display !== 'none') {
closeViewModal();
}
}
});
if (typeof window !== 'undefined') {
window.showViewModal = showViewModal;
window.closeViewModal = closeViewModal;
}