From 9c81a13c695043d4cdd80d42b4778c5b7bed76d4 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 16 Mar 2026 08:39:06 -0400 Subject: [PATCH] created price-history.ts to get history data and added to modal via chart.js --- src/assets/js/priceChart.js | 214 +++++++++++++ src/db/schema.ts | 203 ++++++------ src/layouts/Main.astro | 3 +- src/pages/partials/card-modal.astro | 471 +++++++++++++++++++--------- src/pages/partials/price-history.ts | 39 +++ 5 files changed, 684 insertions(+), 246 deletions(-) create mode 100644 src/assets/js/priceChart.js create mode 100644 src/pages/partials/price-history.ts diff --git a/src/assets/js/priceChart.js b/src/assets/js/priceChart.js new file mode 100644 index 0000000..e37ae86 --- /dev/null +++ b/src/assets/js/priceChart.js @@ -0,0 +1,214 @@ +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)' }, + "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 }; + +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 buildChartData(history, rangeKey) { + const cutoff = new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000); + + const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff); + + const allDates = [...new Set(filtered.map(r => r.calculatedAt))] + .sort((a, b) => new Date(a) - new Date(b)); + + const labels = allDates.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 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, + pointHoverRadius: isActive ? 5 : 3, + pointBackgroundColor: isActive ? colors.active : colors.muted, + tension: 0.3, + fill: false, + spanGaps: true, + order: isActive ? 0 : 1, + }; + }); + + return { labels, datasets }; +} + +function updateChart() { + if (!chartInstance) return; + const { labels, datasets } = buildChartData(allHistory, activeRange); + chartInstance.data.labels = labels; + chartInstance.data.datasets = datasets; + chartInstance.update('none'); +} + +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) { + console.warn('No price history data for this card'); + return; + } + + const { labels, datasets } = buildChartData(allHistory, activeRange); + + 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 setupObserver() { + const modal = document.getElementById('cardModal'); + if (!modal) { + console.error('cardModal element not found'); + return; + } + + const observer = new MutationObserver(() => { + const canvas = document.getElementById('priceHistoryChart'); + if (!canvas) 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); + }); + + observer.observe(modal, { childList: true, subtree: true }); + + modal.addEventListener('hidden.bs.modal', () => { + 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) => { + 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 diff --git a/src/db/schema.ts b/src/db/schema.ts index 94c1436..6092821 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -5,110 +5,111 @@ export const pokeSchema = pgSchema("pokemon"); export const tcgcards = pokeSchema.table('tcg_cards', { productId: integer().primaryKey(), - productName: varchar({ length: 255 }).notNull(), - productLineName: varchar({ length: 255 }).default("").notNull(), - productLineUrlName: varchar({ length: 255 }).default("").notNull(), - productStatusId: integer().default(0).notNull(), - productTypeId: integer().default(0).notNull(), - productUrlName: varchar({ length: 255 }).default("").notNull(), - rarityName: varchar({ length: 100 }).default("").notNull(), - sealed: boolean().default(false).notNull(), - sellerListable: boolean().default(false).notNull(), - setId: integer(), - shippingCategoryId: integer(), - duplicate: boolean().default(false).notNull(), - foilOnly: boolean().default(false).notNull(), - maxFulfillableQuantity: integer(), - totalListings: integer(), - score: decimal({ precision: 10, scale: 2, mode: 'number' }), - lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }), - lowestPriceWithShipping: decimal({ precision: 10, scale: 2, mode: 'number' }), - marketPrice: decimal({ precision: 10, scale: 2, mode: 'number' }), - medianPrice: decimal({ precision: 10, scale: 2, mode: 'number' }), - attack1: varchar({ length: 1024 }), - attack2: varchar({ length: 1024 }), - attack3: varchar({ length: 1024 }), - attack4: varchar({ length: 1024 }), - cardType: varchar({ length: 100 }), - cardTypeB: varchar({ length: 100 }), - energyType: varchar({ length: 100 }), - flavorText: varchar({ length: 1000 }), - hp: integer(), - number: varchar({ length: 50 }).default("").notNull(), - releaseDate: timestamp(), - resistance: varchar({ length: 100 }), - retreatCost: varchar({ length: 100 }), - stage: varchar({ length: 100 }), - weakness: varchar({ length: 100 }), - artist: varchar({ length: 255 }), -}); + productName: varchar({ length: 255 }).notNull(), + productLineName: varchar({ length: 255 }).default("").notNull(), + productLineUrlName: varchar({ length: 255 }).default("").notNull(), + productStatusId: integer().default(0).notNull(), + productTypeId: integer().default(0).notNull(), + productUrlName: varchar({ length: 255 }).default("").notNull(), + rarityName: varchar({ length: 100 }).default("").notNull(), + sealed: boolean().default(false).notNull(), + sellerListable: boolean().default(false).notNull(), + setId: integer(), + shippingCategoryId: integer(), + duplicate: boolean().default(false).notNull(), + foilOnly: boolean().default(false).notNull(), + maxFulfillableQuantity: integer(), + totalListings: integer(), + score: decimal({ precision: 10, scale: 2, mode: 'number' }), + lowestPrice: decimal({ precision: 10, scale: 2, mode: 'number' }), + lowestPriceWithShipping: decimal({ precision: 10, scale: 2, mode: 'number' }), + marketPrice: decimal({ precision: 10, scale: 2, mode: 'number' }), + medianPrice: decimal({ precision: 10, scale: 2, mode: 'number' }), + attack1: varchar({ length: 1024 }), + attack2: varchar({ length: 1024 }), + attack3: varchar({ length: 1024 }), + attack4: varchar({ length: 1024 }), + cardType: varchar({ length: 100 }), + cardTypeB: varchar({ length: 100 }), + energyType: varchar({ length: 100 }), + flavorText: varchar({ length: 1000 }), + hp: integer(), + number: varchar({ length: 50 }).default("").notNull(), + releaseDate: timestamp(), + resistance: varchar({ length: 100 }), + retreatCost: varchar({ length: 100 }), + stage: varchar({ length: 100 }), + weakness: varchar({ length: 100 }), + artist: varchar({ length: 255 }), + }); -export const cards = pokeSchema.table('cards', { - cardId: integer().notNull().primaryKey().generatedAlwaysAsIdentity(), - productId: integer().notNull(), - variant: varchar({ length: 100 }).notNull(), - productName: varchar({ length: 255 }), - productLineName: varchar({ length: 255 }), - productUrlName: varchar({ length: 255 }).default("").notNull(), - rarityName: varchar({ length: 100 }), - sealed: boolean().default(false).notNull(), - setId: integer(), - cardType: varchar({ length: 100 }), - energyType: varchar({ length: 100 }), - number: varchar({ length: 50 }), - artist: varchar({ length: 255 }), -}, -(table) => [ - index('idx_card_product_id').on(table.productId, table.variant), -]); + export const cards = pokeSchema.table('cards', { + cardId: integer().notNull().primaryKey().generatedAlwaysAsIdentity(), + productId: integer().notNull(), + variant: varchar({ length: 100 }).notNull(), + productName: varchar({ length: 255 }), + productLineName: varchar({ length: 255 }), + productUrlName: varchar({ length: 255 }).default("").notNull(), + rarityName: varchar({ length: 100 }), + sealed: boolean().default(false).notNull(), + setId: integer(), + cardType: varchar({ length: 100 }), + energyType: varchar({ length: 100 }), + number: varchar({ length: 50 }), + artist: varchar({ length: 255 }), + }, + (table) => [ + index('idx_card_product_id').on(table.productId, table.variant), + ]); -export const tcg_overrides = pokeSchema.table('tcg_overrides', { - productId: integer().primaryKey(), - productName: varchar({ length: 255 }), - productLineName: varchar({ length: 255 }), - productUrlName: varchar({ length: 255 }).default('').notNull(), - rarityName: varchar({ length: 100 }), - sealed: boolean().default(false).notNull(), - setId: integer(), - cardType: varchar({ length: 100 }), - energyType: varchar({ length: 100 }), - number: varchar({ length: 50 }), - artist: varchar({ length: 255 }), -}); + export const tcg_overrides = pokeSchema.table('tcg_overrides', { + productId: integer().primaryKey(), + productName: varchar({ length: 255 }), + productLineName: varchar({ length: 255 }), + productUrlName: varchar({ length: 255 }).default('').notNull(), + rarityName: varchar({ length: 100 }), + sealed: boolean().default(false).notNull(), + setId: integer(), + cardType: varchar({ length: 100 }), + energyType: varchar({ length: 100 }), + number: varchar({ length: 50 }), + artist: varchar({ length: 255 }), + }); -export const sets = pokeSchema.table('sets', { - setId: integer().primaryKey(), - setName: varchar({ length: 255 }).notNull(), - setUrlName: varchar({ length: 255 }).notNull(), - setCode: varchar({ length: 100 }).notNull(), -}); + export const sets = pokeSchema.table('sets', { + setId: integer().primaryKey(), + setName: varchar({ length: 255 }).notNull(), + setUrlName: varchar({ length: 255 }).notNull(), + setCode: varchar({ length: 100 }).notNull(), + }); -export const skus = pokeSchema.table('skus', { - skuId: integer().primaryKey(), - cardId: integer().default(0).notNull(), - productId: integer().notNull(), - condition: varchar({ length: 255 }).notNull(), - language: varchar({ length: 100 }).notNull(), - variant: varchar({ length: 100 }).notNull(), - calculatedAt: timestamp(), - highestPrice: decimal({ precision: 10, scale: 2 }), - lowestPrice: decimal({ precision: 10, scale: 2 }), - marketPrice: decimal({ precision: 10, scale: 2 }), - priceCount: integer(), -}, -(table) => [ - index('idx_product_id_condition').on(table.productId, table.variant), -]); + export const skus = pokeSchema.table('skus', { + skuId: integer().primaryKey(), + cardId: integer().default(0).notNull(), + productId: integer().notNull(), + condition: varchar({ length: 255 }).notNull(), + language: varchar({ length: 100 }).notNull(), + variant: varchar({ length: 100 }).notNull(), + calculatedAt: timestamp(), + highestPrice: decimal({ precision: 10, scale: 2 }), + lowestPrice: decimal({ precision: 10, scale: 2 }), + marketPrice: decimal({ precision: 10, scale: 2 }), + priceCount: integer(), + }, + (table) => [ + index('idx_product_id_condition').on(table.productId, table.variant), + ]); -export const priceHistory = pokeSchema.table('price_history', { - skuId: integer().notNull(), - calculatedAt: timestamp().notNull(), - marketPrice: decimal({ precision: 10, scale: 2 }), -}, -(table) => [ - primaryKey({ name: 'pk_price_history', columns: [table.skuId, table.calculatedAt] }) -]); + export const priceHistory = pokeSchema.table('price_history', { + skuId: integer().notNull(), + calculatedAt: timestamp().notNull(), + marketPrice: decimal({ precision: 10, scale: 2 }), + }, + (table) => [ + primaryKey({ name: 'pk_price_history', columns: [table.skuId, table.calculatedAt] }) + ]); -export const processingSkus = pokeSchema.table('processing_skus', { - skuId: integer().primaryKey(), -}); + export const processingSkus = pokeSchema.table('processing_skus', { + skuId: integer().primaryKey(), + }); + \ No newline at end of file diff --git a/src/layouts/Main.astro b/src/layouts/Main.astro index 4015587..6e24c4f 100644 --- a/src/layouts/Main.astro +++ b/src/layouts/Main.astro @@ -38,7 +38,8 @@ const { title } = Astro.props; - + + \ No newline at end of file diff --git a/src/pages/partials/card-modal.astro b/src/pages/partials/card-modal.astro index c9d45d2..57625f2 100644 --- a/src/pages/partials/card-modal.astro +++ b/src/pages/partials/card-modal.astro @@ -4,7 +4,6 @@ import SetIcon from '../../components/SetIcon.astro'; import EnergyIcon from '../../components/EnergyIcon.astro'; import RarityIcon from '../../components/RarityIcon.astro'; import { db } from '../../db/index'; -import { privateDecrypt } from "node:crypto"; import FirstEditionIcon from "../../components/FirstEditionIcon.astro"; export const partial = true; @@ -13,30 +12,23 @@ export const prerender = false; const searchParams = Astro.url.searchParams; const cardId = Number(searchParams.get('cardId')) || 0; -// query the database for the card with the given productId and return the card data as json const card = await db.query.cards.findFirst({ where: { cardId: Number(cardId) }, with: { - prices: true, + prices: { + with: { + history: { + orderBy: (ph, { asc }) => [asc(ph.calculatedAt)], + } + } + }, set: true, } }); -// Get the current card's position in the grid and find previous/next cards -// This assumes cards are displayed in a specific order in the DOM -const cardElements = typeof document !== 'undefined' ? document.querySelectorAll('[data-card-id]') : []; -let prevCardId = null; -let nextCardId = null; - -// Since this is server-side, we can't access the DOM directly -// Instead, we'll pass the current cardId and let JavaScript handle navigation -// The JS will look for the next/prev cards in the grid based on the visible cards - function timeAgo(date: Date | null) { if (!date) return "Not applicable"; - const seconds = Math.floor((Date.now() - date.getTime()) / 1000); - const intervals: Record = { year: 31536000, month: 2592000, @@ -44,31 +36,35 @@ function timeAgo(date: Date | null) { hour: 3600, minute: 60 }; - for (const [unit, value] of Object.entries(intervals)) { const count = Math.floor(seconds / value); if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`; } - return "just now"; } -// Get the most recent calculatedAt across all prices const calculatedAt = (() => { if (!card?.prices?.length) return null; - - // Extract all valid calculatedAt timestamps const dates = card.prices .map(p => p.calculatedAt) - .filter(d => d) // remove null/undefined + .filter(d => d) .map(d => new Date(d)); - if (!dates.length) return null; - - // Return the most recent one return new Date(Math.max(...dates.map(d => d.getTime()))); })(); +// Flatten all price history across all skus into a single array for the chart, +// normalising calculatedAt to a YYYY-MM-DD string to avoid timezone/dedup issues +const priceHistoryForChart = (card?.prices ?? []).flatMap(sku => + (sku.history ?? []).map(ph => ({ + condition: sku.condition, + calculatedAt: ph.calculatedAt + ? new Date(ph.calculatedAt).toISOString().split('T')[0] + : null, + marketPrice: ph.marketPrice, + })) +).filter(r => r.calculatedAt !== null); + const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; const conditionAttributes = (price: any) => { @@ -76,13 +72,10 @@ const conditionAttributes = (price: any) => { const market = price?.marketPrice; const low = price?.lowestPrice; const high = price?.highestPrice; - if (market == null || low == null || high == null || Number(market) === 0) { return "Indeterminate"; } - const spreadPct = (Number(high) - Number(low)) / Number(market) * 100; - if (spreadPct >= 81) return "High"; if (spreadPct >= 59) return "Medium"; return "Low"; @@ -90,21 +83,21 @@ const conditionAttributes = (price: any) => { const volatilityClass = (() => { switch (volatility) { - case "High": return "alert-danger"; - case "Medium": return "alert-warning"; - case "Low": return "alert-success"; - default: return "alert-dark"; // Indeterminate + case "High": return "alert-danger"; + case "Medium": return "alert-warning"; + case "Low": return "alert-success"; + default: return "alert-dark"; } })(); const condition: string = price?.condition || "Near Mint"; return { - "Near Mint": { label: "nav-nm", volatility, volatilityClass, class: "show active" }, - "Lightly Played": { label: "nav-lp", volatility, volatilityClass }, - "Moderately Played": { label: "nav-mp", volatility, volatilityClass }, - "Heavily Played": { label: "nav-hp", volatility, volatilityClass }, - "Damaged": { label: "nav-dmg", volatility, volatilityClass } + "Near Mint": { label: "nav-nm", volatility, volatilityClass, class: "show active" }, + "Lightly Played": { label: "nav-lp", volatility, volatilityClass }, + "Moderately Played":{ label: "nav-mp", volatility, volatilityClass }, + "Heavily Played": { label: "nav-hp", volatility, volatilityClass }, + "Damaged": { label: "nav-dmg", volatility, volatilityClass } }[condition]; }; @@ -121,125 +114,315 @@ 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 new file mode 100644 index 0000000..f4f87fb --- /dev/null +++ b/src/pages/partials/price-history.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from 'astro'; +import { db } from '../../db/index'; +import { priceHistory, skus } from '../../db/schema'; +import { eq, inArray } from 'drizzle-orm'; + +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) + .where(eq(skus.cardId, cardId)); + + if (!cardSkus.length) { + return new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' } }); + } + + const skuIds = cardSkus.map(s => s.skuId); + + // Fetch price history for all skus + const history = await db + .select({ + skuId: priceHistory.skuId, + calculatedAt: priceHistory.calculatedAt, + marketPrice: priceHistory.marketPrice, + condition: skus.condition, + }) + .from(priceHistory) + .innerJoin(skus, eq(priceHistory.skuId, skus.skuId)) + .where(inArray(priceHistory.skuId, skuIds)) + .orderBy(priceHistory.calculatedAt); + + return new Response(JSON.stringify(history), { + headers: { 'Content-Type': 'application/json' } + }); +}; \ No newline at end of file