From c4ebbfb060bbcf5257f2f2e574eb31d2de5945e6 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 16 Mar 2026 11:05:10 -0400 Subject: [PATCH] modified layout and made it so you can switch between card modals and keep the pricing chart --- src/assets/js/priceChart.js | 142 +++++---- src/components/CardGrid.astro | 47 +-- src/pages/partials/card-modal.astro | 440 +++++++++++----------------- src/pages/partials/price-history.ts | 40 ++- 4 files changed, 323 insertions(+), 346 deletions(-) diff --git a/src/assets/js/priceChart.js b/src/assets/js/priceChart.js index e37ae86..48e7543 100644 --- a/src/assets/js/priceChart.js +++ b/src/assets/js/priceChart.js @@ -2,7 +2,6 @@ import Chart from 'chart.js/auto'; const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; -// Match the $tiers colors from your SCSS exactly 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)' }, @@ -11,7 +10,7 @@ const CONDITION_COLORS = { "Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' }, }; -const RANGE_DAYS = { '1m': 30, '3m': 90 }; +const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity }; let chartInstance = null; let allHistory = []; @@ -24,8 +23,19 @@ function formatDate(dateStr) { 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 = new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000); + 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); @@ -40,17 +50,22 @@ function buildChartData(history, rangeKey) { lookup[row.condition][row.calculatedAt] = Number(row.marketPrice); } + // Check specifically whether the active condition has any data points + const activeConditionDates = allDates.filter( + date => lookup[activeCondition]?.[date] != null + ); + const activeConditionHasData = activeConditionDates.length > 0; + const datasets = CONDITIONS.map(condition => { const isActive = condition === activeCondition; const colors = CONDITION_COLORS[condition]; const data = allDates.map(date => lookup[condition]?.[date] ?? null); - return { label: condition, data, borderColor: isActive ? colors.active : colors.muted, - borderWidth: isActive ? 2.5 : 1, - pointRadius: isActive ? 3 : 0, + borderWidth: isActive ? 2 : 1, + pointRadius: isActive ? 2.5 : 0, pointHoverRadius: isActive ? 5 : 3, pointBackgroundColor: isActive ? colors.active : colors.muted, tension: 0.3, @@ -60,12 +75,20 @@ function buildChartData(history, rangeKey) { }; }); - return { labels, datasets }; + return { labels, datasets, hasData: allDates.length > 0, activeConditionHasData }; } function updateChart() { if (!chartInstance) return; - const { labels, datasets } = buildChartData(allHistory, activeRange); + const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange); + + // Show empty state if no data at all, or if the active condition specifically has no data + if (!hasData || !activeConditionHasData) { + setEmptyState(true); + return; + } + + setEmptyState(false); chartInstance.data.labels = labels; chartInstance.data.datasets = datasets; chartInstance.update('none'); @@ -85,11 +108,18 @@ function initPriceChart(canvas) { } if (!allHistory.length) { - console.warn('No price history data for this card'); + setEmptyState(true); return; } - const { labels, datasets } = buildChartData(allHistory, activeRange); + const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange); + + if (!hasData || !activeConditionHasData) { + setEmptyState(true); + return; + } + + setEmptyState(false); chartInstance = new Chart(canvas.getContext('2d'), { type: 'line', @@ -151,64 +181,54 @@ function initPriceChart(canvas) { }); } -function setupObserver() { +function initFromCanvas(canvas) { + activeCondition = "Near Mint"; + activeRange = '1m'; const modal = document.getElementById('cardModal'); - if (!modal) { - console.error('cardModal element not found'); - return; - } + modal?.querySelectorAll('.price-range-btn').forEach(b => { + b.classList.toggle('active', b.dataset.range === '1m'); + }); + initPriceChart(canvas); +} - const observer = new MutationObserver(() => { - const canvas = document.getElementById('priceHistoryChart'); - if (!canvas) return; +function setup() { + const modal = document.getElementById('cardModal'); + if (!modal) return; - // Disconnect immediately so tab switches don't retrigger initPriceChart - observer.disconnect(); - - activeCondition = "Near Mint"; - activeRange = '1m'; - document.querySelectorAll('.price-range-btn').forEach(b => { - b.classList.toggle('active', b.dataset.range === '1m'); - }); - - initPriceChart(canvas); + modal.addEventListener('card-modal:swapped', () => { + const canvas = modal.querySelector('#priceHistoryChart'); + if (canvas) initFromCanvas(canvas); }); - observer.observe(modal, { childList: true, subtree: true }); - modal.addEventListener('hidden.bs.modal', () => { - if (chartInstance) { - chartInstance.destroy(); - chartInstance = null; - } + if (chartInstance) { chartInstance.destroy(); chartInstance = null; } allHistory = []; - // Reconnect so the next card open is detected - observer.observe(modal, { childList: true, subtree: true }); + }); + + 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(); }); } -document.addEventListener('shown.bs.tab', (e) => { - 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) return; - document.querySelectorAll('.price-range-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - activeRange = btn.dataset.range ?? '1m'; - updateChart(); -}); - -setupObserver(); \ No newline at end of file +setup(); \ No newline at end of file diff --git a/src/components/CardGrid.astro b/src/components/CardGrid.astro index a4fee4c..7ceb61b 100644 --- a/src/components/CardGrid.astro +++ b/src/components/CardGrid.astro @@ -41,7 +41,6 @@ import BackToTop from "./BackToTop.astro" - @@ -188,50 +247,87 @@ const altSearchUrl = (card: any) => { const attributes = conditionAttributes(price); return (
- -
-
-
Market Price
-

${price.marketPrice}

-
-
-
Lowest Price
-

${price.lowestPrice}

-
-
-
Highest Price
-

${price.highestPrice}

-
-
-
Volatility
-

{attributes?.volatility}

+
+ + +
+
+
Market Price
+

${price.marketPrice}

+
+
+
Lowest Price
+

${price.lowestPrice}

+
+
+
Highest Price
+

${price.highestPrice}

+
+
+
Volatility
+

{attributes?.volatility}

+
+
+ + +
+
+
Latest Verified Sales
+ + + + + + + + + + + + + + + + +
Filtered to remove mismatched language variants
DateTitlePrice
+
+
); })} - + + +
+
+
+
Market Price History
+
+ No sales data for the selected period/condition +
+
+ + +
+
+ {showRanges['1m'] && } + {showRanges['3m'] && } + {showRanges['6m'] && } + {showRanges['1y'] && } + {showRanges['all'] && } +
+
- -
-
Market Price History
-
- - -
-
- - -
-
@@ -247,182 +343,4 @@ const altSearchUrl = (card: any) => {
- - - \ No newline at end of file + \ No newline at end of file diff --git a/src/pages/partials/price-history.ts b/src/pages/partials/price-history.ts index f4f87fb..3e0aa33 100644 --- a/src/pages/partials/price-history.ts +++ b/src/pages/partials/price-history.ts @@ -8,7 +8,6 @@ export const prerender = false; export const GET: APIRoute = async ({ url }) => { const cardId = Number(url.searchParams.get('cardId')) || 0; - // Get all skus for this card const cardSkus = await db .select() .from(skus) @@ -20,8 +19,7 @@ export const GET: APIRoute = async ({ url }) => { const skuIds = cardSkus.map(s => s.skuId); - // Fetch price history for all skus - const history = await db + const historyRows = await db .select({ skuId: priceHistory.skuId, calculatedAt: priceHistory.calculatedAt, @@ -33,7 +31,41 @@ export const GET: APIRoute = async ({ url }) => { .where(inArray(priceHistory.skuId, skuIds)) .orderBy(priceHistory.calculatedAt); - return new Response(JSON.stringify(history), { + // Rolling 30-day cutoff for volatility + const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000); + + const byCondition: Record = {}; + for (const row of historyRows) { + if (row.marketPrice == null) continue; + if (!row.calculatedAt) continue; + if (new Date(row.calculatedAt) < thirtyDaysAgo) continue; + const price = Number(row.marketPrice); + if (price <= 0) continue; + if (!byCondition[row.condition]) byCondition[row.condition] = []; + byCondition[row.condition].push(price); + } + + function computeVolatility(prices: number[]): { label: string; monthlyVol: number } { + if (prices.length < 2) return { label: '—', monthlyVol: 0 }; + const returns: number[] = []; + for (let i = 1; i < prices.length; i++) { + returns.push(Math.log(prices[i] / prices[i - 1])); + } + const mean = returns.reduce((a, b) => a + b, 0) / returns.length; + const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1); + const monthlyVol = Math.sqrt(variance) * Math.sqrt(30); + const label = monthlyVol >= 0.30 ? 'High' + : monthlyVol >= 0.15 ? 'Medium' + : 'Low'; + return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 }; + } + + const volatilityByCondition: Record = {}; + for (const [condition, prices] of Object.entries(byCondition)) { + volatilityByCondition[condition] = computeVolatility(prices); + } + + return new Response(JSON.stringify({ history: historyRows, volatilityByCondition }), { headers: { 'Content-Type': 'application/json' } }); }; \ No newline at end of file