import Chart from 'chart.js/auto'; const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; const CONDITION_COLORS = { "Near Mint": { active: 'rgba(156, 204, 102, 1)', muted: 'rgba(156, 204, 102, 0.67)' }, "Lightly Played": { active: 'rgba(211, 225, 86, 1)', muted: 'rgba(211, 225, 86, 0.67)' }, "Moderately Played": { active: 'rgba(255, 238, 87, 1)', muted: 'rgba(255, 238, 87, 0.67)' }, "Heavily Played": { active: 'rgba(255, 201, 41, 1)', muted: 'rgba(255, 201, 41, 0.67)' }, "Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' }, }; const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity }; let chartInstance = null; let allHistory = []; let activeCondition = "Near Mint"; let activeRange = '1m'; function formatDate(dateStr) { const [year, month, day] = dateStr.split('-'); const d = new Date(Number(year), Number(month) - 1, Number(day)); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function setEmptyState(isEmpty) { const modal = document.getElementById('cardModal'); const empty = modal?.querySelector('#priceHistoryEmpty'); const canvasWrapper = empty?.nextElementSibling; if (!empty || !canvasWrapper) return; empty.classList.toggle('d-none', !isEmpty); canvasWrapper.classList.toggle('d-none', isEmpty); } function buildChartData(history, rangeKey) { const cutoff = RANGE_DAYS[rangeKey] === Infinity ? new Date(0) : new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000); const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff); // Always build the full date axis for the selected window, even if sparse. // Generate one label per day in the range so the x-axis reflects the // chosen period rather than collapsing to only the days that have data. const dataDateSet = new Set(filtered.map(r => r.calculatedAt)); const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b)); // If we have real data, expand the axis to span from cutoff → today so // empty stretches at the start/end of a range are visible. let axisLabels = allDates; if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) { const start = new Date(cutoff); const end = new Date(); const expanded = []; // Step through every day in the window for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { expanded.push(d.toISOString().split('T')[0]); } axisLabels = expanded; } const labels = axisLabels.map(formatDate); const lookup = {}; for (const row of filtered) { if (!lookup[row.condition]) lookup[row.condition] = {}; lookup[row.condition][row.calculatedAt] = Number(row.marketPrice); } const activeConditionHasData = allDates.some( date => lookup[activeCondition]?.[date] != null ); const datasets = CONDITIONS.map(condition => { const isActive = condition === activeCondition; const colors = CONDITION_COLORS[condition]; const data = axisLabels.map(date => lookup[condition]?.[date] ?? null); return { label: condition, data, borderColor: isActive ? colors.active : colors.muted, borderWidth: isActive ? 2 : 1, pointRadius: isActive ? 2.5 : 0, pointHoverRadius: isActive ? 5 : 3, pointBackgroundColor: isActive ? colors.active : colors.muted, tension: 0.3, fill: false, spanGaps: true, order: isActive ? 0 : 1, }; }); return { labels, datasets, hasData: allDates.length > 0, activeConditionHasData, }; } function updateChart() { if (!chartInstance) return; const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange); // Always push the new labels/datasets to the chart so the x-axis // reflects the selected time window — even when there's no data for // the active condition. Then toggle the empty state overlay on top. chartInstance.data.labels = labels; chartInstance.data.datasets = datasets; chartInstance.update('none'); // Show the empty state overlay if the active condition has no points // in this window, but leave the (empty) chart visible underneath so // the axis communicates the selected period. setEmptyState(!hasData || !activeConditionHasData); } function initPriceChart(canvas) { if (chartInstance) { chartInstance.destroy(); chartInstance = null; } try { allHistory = JSON.parse(canvas.dataset.history ?? '[]'); } catch (err) { console.error('Failed to parse price history:', err); return; } if (!allHistory.length) { setEmptyState(true); return; } const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange); // Render the chart regardless — show empty state overlay if needed setEmptyState(!hasData || !activeConditionHasData); chartInstance = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false, }, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.85)', titleColor: 'rgba(255, 255, 255, 0.9)', bodyColor: 'rgba(255, 255, 255, 0.75)', borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, padding: 10, callbacks: { labelColor: (ctx) => { const colors = CONDITION_COLORS[ctx.dataset.label]; return { borderColor: colors.active, backgroundColor: colors.active, }; }, label: (ctx) => { const isActive = ctx.dataset.label === activeCondition; const price = ctx.parsed.y != null ? `$${ctx.parsed.y.toFixed(2)}` : '—'; return isActive ? ` ${ctx.dataset.label}: ${price} ◀` : ` ${ctx.dataset.label}: ${price}`; } } } }, scales: { x: { grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: 'rgba(255, 255, 255, 0.4)', maxTicksLimit: 6, maxRotation: 0, }, border: { color: 'rgba(255, 255, 255, 0.1)' }, }, y: { grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: 'rgba(255, 255, 255, 0.4)', callback: (val) => `$${Number(val).toFixed(2)}`, }, border: { color: 'rgba(255, 255, 255, 0.1)' }, } } } }); } function initFromCanvas(canvas) { activeCondition = "Near Mint"; activeRange = '1m'; const modal = document.getElementById('cardModal'); modal?.querySelectorAll('.price-range-btn').forEach(b => { b.classList.toggle('active', b.dataset.range === '1m'); }); initPriceChart(canvas); } function setup() { const modal = document.getElementById('cardModal'); if (!modal) return; modal.addEventListener('card-modal:swapped', () => { const canvas = modal.querySelector('#priceHistoryChart'); if (canvas) initFromCanvas(canvas); }); modal.addEventListener('hidden.bs.modal', () => { if (chartInstance) { chartInstance.destroy(); chartInstance = null; } allHistory = []; }); document.addEventListener('shown.bs.tab', (e) => { if (!modal.contains(e.target)) return; const target = e.target?.getAttribute('data-bs-target'); const conditionMap = { '#nav-nm': 'Near Mint', '#nav-lp': 'Lightly Played', '#nav-mp': 'Moderately Played', '#nav-hp': 'Heavily Played', '#nav-dmg': 'Damaged', }; if (target && conditionMap[target]) { activeCondition = conditionMap[target]; updateChart(); } }); document.addEventListener('click', (e) => { const btn = e.target?.closest('.price-range-btn'); if (!btn || !modal.contains(btn)) return; modal.querySelectorAll('.price-range-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); activeRange = btn.dataset.range ?? '1m'; updateChart(); }); } setup();