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;
-
+
+