15 Commits

Author SHA1 Message Date
c0120e3e77 [feat] read tcgcollector csv 2026-03-18 13:39:39 -04:00
660da7cded read/write CSV, prices from db 2026-03-18 13:26:42 -04:00
2a17654c74 [chore] sales history schema 2026-03-18 11:14:19 -04:00
zach
ee9f7a2561 added the mechanism for sort by, added total results and made it all look nice in one row 2026-03-16 14:39:55 -04:00
zach
2f17912949 reqrote volatility with proper standard deviation and added tooltip 2026-03-16 14:07:37 -04:00
a86dc08b50 [bugfix] fixing schema messed up by something adding tabs 2026-03-16 13:54:50 -04:00
zach
c4ebbfb060 modified layout and made it so you can switch between card modals and keep the pricing chart 2026-03-16 11:05:10 -04:00
zach
9c81a13c69 created price-history.ts to get history data and added to modal via chart.js 2026-03-16 08:39:06 -04:00
3a6dbf2ed9 [chore] preload all price history 2026-03-14 23:50:14 -04:00
e1ab59a2eb [feat] price history 2026-03-12 22:31:29 -04:00
zach
a8df9c71ee Merge branch 'master' of papi.tkpups.com:tmiller/pokemon 2026-03-12 13:41:02 -04:00
zach
835a174da2 refactored 404 page, fixed copy image toast on mobile and filtered missing images to exclude sealed 2026-03-12 13:40:12 -04:00
485f26de7b [chore] refactor indexing scripts 2026-03-12 08:18:40 -04:00
c10e34cc34 [feat] move missing image script out of test scripts so it's picked up by git 2026-03-11 23:09:35 -04:00
d9995e5e10 [bugfix] escape facet filters so special characters like parentheses work 2026-03-11 20:33:43 -04:00
22 changed files with 1373 additions and 503 deletions

129
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"csv": "^6.4.1",
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"drizzle-orm": "^1.0.0-beta.15-859cf75", "drizzle-orm": "^1.0.0-beta.15-859cf75",
"pg": "^8.20.0", "pg": "^8.20.0",
@@ -2613,16 +2614,6 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.6", "version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
@@ -3080,6 +3071,39 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csv": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/csv/-/csv-6.4.1.tgz",
"integrity": "sha512-ajGosmTGnTwYyGl8STqZDu7R6LkDf3xL39XiOmliV/GufQeVUxHzTKIm4NOBCwmEuujK7B6isxs4Uqt9GcRCvA==",
"license": "MIT",
"dependencies": {
"csv-generate": "^4.5.0",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
"stream-transform": "^3.4.0"
},
"engines": {
"node": ">= 0.1.90"
}
},
"node_modules/csv-generate": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.5.0.tgz",
"integrity": "sha512-aQr/vmOKyBSBHNwYhAoXw1+kUsPnMSwmYgpNoo36rIXoG1ecWILnvPGZeQ6oUjzrWknZAD3+jfpqYOBAl4x15A==",
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
"license": "MIT"
},
"node_modules/csv-stringify": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.6.0.tgz",
"integrity": "sha512-YW32lKOmIBgbxtu3g5SaiqWNwa/9ISQt2EcgOq0+RAIFufFp9is6tqNnKahqE5kuKvrnYAzs28r+s6pXJR8Vcw==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3165,16 +3189,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -4342,16 +4356,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-east-asian-width": { "node_modules/get-east-asian-width": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
@@ -4883,13 +4887,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT",
"optional": true
},
"node_modules/is-wsl": { "node_modules/is-wsl": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
@@ -5046,13 +5043,6 @@
"url": "https://tidelift.com/funding/github/npm/loglevel" "url": "https://tidelift.com/funding/github/npm/loglevel"
} }
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -5072,22 +5062,6 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"optional": true,
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -6014,19 +5988,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"optional": true,
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
@@ -7090,22 +7051,6 @@
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"optional": true,
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/standardwebhooks": { "node_modules/standardwebhooks": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -7131,6 +7076,12 @@
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/stream-transform": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.4.0.tgz",
"integrity": "sha512-QO3OGhKyeIV8p6eRQdG+W6WounFw519zk690hHCNfhgfP9bylVS+NTXsuBc7n+RsGn31UgFPGrWYIgoAbArKEw==",
"license": "MIT"
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",

View File

@@ -17,6 +17,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"csv": "^6.4.1",
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"drizzle-orm": "^1.0.0-beta.15-859cf75", "drizzle-orm": "^1.0.0-beta.15-859cf75",
"pg": "^8.20.0", "pg": "^8.20.0",

87
scripts/csvprices.ts Normal file
View File

@@ -0,0 +1,87 @@
import 'dotenv/config';
import { db, ClosePool } from '../src/db/index.ts';
import chalk from 'chalk';
import fs from "fs";
//import path from "node:path";
import { parse, stringify, transform } from 'csv';
import { client } from '../src/db/typesense.ts';
async function PricesFromCSV() {
const inputFilePath = 'scripts/test.tcgcollector.csv';
const outputFilePath = 'scripts/output.csv';
// Create read and write streams
const inputStream = fs.createReadStream(inputFilePath, 'utf8');
const outputStream = fs.createWriteStream(outputFilePath);
// Define the transformation logic
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
try {
// Specific query bsaed on tcgcollector CSV
const query = String(Object.values(row)[1]);
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
const cardNumber = String(Object.values(row)[7]);
console.log(`${query} ${cardNumber} : ${setname}`);
// Use Typesense to find the card because we can easily use the combined fields
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
if (cards.hits?.length === 0) {
// Try without card number
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
}
if (cards.hits?.length === 0) {
// Try without set name
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
}
if (cards.hits?.length === 0) {
// I give up, just output the values from the csv
console.log(chalk.red(' - not found'));
const newRow = { ...row };
newRow.Variant = '';
newRow.marketPrice = '';
this.push(newRow);
}
else {
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
const variant = await db.query.cards.findFirst({
with: { prices: true, tcgdata: true },
where: { cardId: card.cardId }
});
const newRow = { ...row };
newRow.Variant = variant?.variant;
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
this.push(newRow);
}
}
callback();
} catch (error) {
callback(error);
}
});
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
inputStream
.on('error', (error) => console.error('Input stream error:', error))
.pipe(parse({ columns: true, trim: true }))
.on('error', (error) => console.error('Parse error:', error))
.pipe(transformer)
.on('error', (error) => console.error('Transform error:', error))
.pipe(stringify({ header: true }))
.on('error', (error) => console.error('Stringify error:', error))
.pipe(outputStream);
outputStream.on('finish', () => {
console.log(`Successfully written to ${outputFilePath}`);
ClosePool();
});
outputStream.on('error', (error) => {
console.error('An error occurred in the process:', error);
ClosePool();
});
}
await PricesFromCSV();

104
scripts/indexing.ts Normal file
View File

@@ -0,0 +1,104 @@
import chalk from 'chalk';
import { client } from '../src/db/typesense.ts';
import type { DBInstance } from '../src/db/index.ts';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
// Delete and recreate the 'cards' index
export const createCardCollection = async () => {
try {
await client.collections('cards').delete();
} catch (error) {
// Ignore error, just means collection doesn't exist
}
await client.collections().create({
name: 'cards',
fields: [
{ name: 'id', type: 'string' },
{ name: 'cardId', type: 'int32' },
{ name: 'productId', type: 'int32' },
{ name: 'variant', type: 'string', facet: true },
{ name: 'productName', type: 'string' },
{ name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true },
{ name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true },
{ name: 'Artist', type: 'string' },
{ name: 'sealed', type: 'bool' },
{ name: 'releaseDate', type: 'int32' },
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
{ name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
],
});
console.log(chalk.green('Collection "cards" created successfully.'));
}
// Delete and recreate the 'skus' index
export const createSkuCollection = async () => {
try {
await client.collections('skus').delete();
} catch (error) {
// Ignore error, just means collection doesn't exist
}
await client.collections().create({
name: 'skus',
fields: [
{ name: 'id', type: 'string' },
{ name: 'condition', type: 'string' },
{ name: 'highestPrice', type: 'int32', optional: true },
{ name: 'lowestPrice', type: 'int32', optional: true },
{ name: 'marketPrice', type: 'int32', optional: true },
]
});
console.log(chalk.green('Collection "skus" created successfully.'));
}
export const upsertCardCollection = async (db:DBInstance) => {
const pokemon = await db.query.cards.findMany({
with: { set: true, tcgdata: true, prices: true },
});
await client.collections('cards').documents().import(pokemon.map(card => {
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
return {
id: card.cardId.toString(),
cardId: card.cardId,
productId: card.productId,
variant: card.variant,
productName: card.productName,
productLineName: card.productLineName,
rarityName: card.rarityName,
setName: card.set?.setName || "",
cardType: card.cardType || "",
energyType: card.energyType || "",
number: card.number,
Artist: card.artist || "",
sealed: card.sealed,
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
...(marketPrice !== null && { marketPrice }),
sku_id: card.prices.map(price => price.skuId.toString())
};
}), { action: 'upsert' });
console.log(chalk.green('Collection "cards" indexed successfully.'));
}
export const upsertSkuCollection = async (db:DBInstance) => {
const skus = await db.query.skus.findMany();
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
})), { action: 'upsert' });
console.log(chalk.green('Collection "skus" indexed successfully.'));
}

View File

@@ -0,0 +1,30 @@
import * as schema from '../src/db/schema.ts';
import { db, ClosePool } from '../src/db/index.ts';
import { sql } from "drizzle-orm";
import fs from "node:fs/promises";
import path from "node:path";
async function findMissingImages() {
const cards = await db
.select()
.from(schema.tcgcards)
.where(sql`${schema.tcgcards.sealed} = false`);
const missingImages: string[] = [];
for (const card of cards) {
const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`);
try {
await fs.access(imagePath);
} catch (err) {
missingImages.push(`${card.productId}\t${card.setId}\t${card.productName}\t${card.number}`);
}
}
return missingImages;
}
const missingImages = await findMissingImages();
//console.log("Missing Images:", missingImages.join('\n'));
fs.writeFile(path.join(process.cwd(), 'missing-images.log'), missingImages.join('\n'));
await ClosePool();

View File

@@ -0,0 +1,147 @@
import chalk from 'chalk';
import { db, ClosePool } from '../src/db/index.ts';
import { sql } from 'drizzle-orm';
import { skus, priceHistory } from '../src/db/schema.ts';
import { toSnakeCase } from 'drizzle-orm/casing';
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const productPath = path.join(__dirname, 'products.log');
const sleep = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
const headers = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
}
const GetHistory = async (productId:number) => {
let monthData;
let quarterData;
let annualData;
let retries = 10;
while (retries > 0) {
try {
const monthResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=month`, { headers: headers });
if (!monthResponse.ok) {
throw new Error(`Error fetching month data: ${monthResponse.statusText}`);
}
monthData = await monthResponse.json();
const quarterResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=quarter`, { headers: headers });
if (!quarterResponse.ok) {
throw new Error(`Error fetching quarter data: ${quarterResponse.statusText}`);
}
quarterData = await quarterResponse.json();
const annualResponse = await fetch(`https://infinite-api.tcgplayer.com/price/history/${productId}/detailed?range=annual`, { headers: headers });
if (!annualResponse.ok) {
throw new Error(`Error fetching annual data: ${annualResponse.statusText}`);
}
annualData = await annualResponse.json();
retries = 0;
}
catch (error) {
retries--;
const err = error as Error;
console.error(err);
if (err.message.startsWith('Error fetching ')) await sleep(7500);
await sleep(2500);
}
}
if (annualData.result === null) {
console.error(chalk.red(`\tNo results found for productId: ${productId}`));
fs.appendFile(productPath, `${productId}\n`);
return null;
}
let skuCount = 0;
let priceCount = 0;
for (const annual of annualData.result) {
const quarter = quarterData.result?.find((r:any) => r.skuId == annual.skuId);
const month = monthData.result?.find((r:any) => r.skuId == annual.skuId);
const allPrices = [
...annual?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || [],
...quarter?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || [],
...month?.buckets?.map((r:any) => { return { skuId:Number(annual.skuId), calculatedAt:r.bucketStartDate, marketPrice:Number(r.marketPrice) }; }) || []
].sort((a:any,b:any) => { if(a.calculatedAt<b.calculatedAt) return -1; if(a.calculatedAt>b.calculatedAt) return 1; return 0; });;
const priceUpdates = allPrices.reduce((accumulator:any[],currentItem:any) => {
if (accumulator.length === 0 || (accumulator[accumulator.length-1].marketPrice !== currentItem.marketPrice && accumulator[accumulator.length-1].calculatedAt != currentItem.calculatedAt)) {
accumulator.push(currentItem);
}
return accumulator;
},[]);
skuCount++;
priceCount += priceUpdates.length;
console.log(chalk.gray(`\tSkuId: ${annual.skuId} with ${priceUpdates.length} updates`));
await db.insert(priceHistory).values(priceUpdates).onConflictDoUpdate({
target: [priceHistory.skuId, priceHistory.calculatedAt ],
set: {
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
},
}).returning();
}
fs.appendFile(productPath, `${productId}\n`);
return { skuCount:skuCount, priceCount:priceCount };
}
const start = Date.now();
let productSet;
try {
const data = await fs.readFile(productPath, 'utf8');
const lines = data.split(/\r?\n/);
productSet = new Set(lines.map(line => line.trim()));
} catch (err) {
productSet = new Set();
}
// problem with this product
productSet.add('632947');
productSet.add('635161');
productSet.add('642504');
productSet.add('654346');
let count = productSet.size;
console.log(chalk.green(`${count} products already done.`));
const productIds = await db.query.tcgcards.findMany({ columns: { productId: true }});
const total = productIds.length;
for (const product of productIds) {
const productId = product.productId;
if (productSet.has(productId.toString().trim())) {
// console.log(chalk.blue(`ProductId: ${productId} (.../${total})`));
} else {
count++;
console.log(chalk.blue(`ProductId: ${productId} (${count}/${total})`));
await GetHistory(productId);
//await sleep(7000);
}
}
await ClosePool();
const end = Date.now();
const duration = (end - start) / 1000;
console.log(chalk.green(`Price history preloaded in ${duration.toFixed(2)} seconds.`));
export {};

View File

@@ -1,134 +1,11 @@
import { Client } from 'typesense';
import chalk from 'chalk'; import chalk from 'chalk';
import { db, ClosePool } from '../src/db/index.ts'; import { db, ClosePool } from '../src/db/index.ts';
import { client } from '../src/db/typesense.ts'; import * as Indexing from './indexing.ts';
import { release } from 'node:os';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
async function createCollection(client: Client) {
// Delete the collection if it already exists to ensure a clean slate
try {
await client.collections('cards').delete();
await client.collections('skus').delete();
//console.log(`Collection "cards" deleted successfully:`, response);
} catch (error) {
//console.error(`Error deleting collection "cards":`, error);
}
// Create the collection with the specified schema
try {
await client.collections('cards').retrieve();
console.log(chalk.yellow('Collection "cards" already exists.'));
} catch (error) {
if (error instanceof Error && error.message.includes('404')) {
await client.collections().create({
name: 'cards',
fields: [
{ name: 'id', type: 'string' },
{ name: 'cardId', type: 'int32' },
{ name: 'productId', type: 'int32' },
{ name: 'variant', type: 'string', facet: true },
{ name: 'productName', type: 'string' },
{ name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true },
{ name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true },
{ name: 'Artist', type: 'string' },
{ name: 'sealed', type: 'bool' },
{ name: 'releaseDate', type: 'int32'},
{ name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
],
//default_sorting_field: 'productId',
});
console.log(chalk.green('Collection "cards" created successfully.'));
} else {
console.error(chalk.red('Error checking/creating collection:'), error);
process.exit(1);
}
}
try {
await client.collections('skus').retrieve();
console.log(chalk.yellow('Collection "skus" already exists.'));
} catch(error) {
if (error instanceof Error && error.message.includes('404')) {
await client.collections().create({
name: 'skus',
fields: [
{ name: 'id', type: 'string' },
{ name: 'condition', type: 'string' },
{ name: 'highestPrice', type: 'int32', optional: true },
{ name: 'lowestPrice', type: 'int32', optional: true },
{ name: 'marketPrice', type: 'int32', optional: true },
//{ name: 'card_id', type: 'string', reference: 'cards.id' },
]
});
}
}
}
async function preloadSearchIndex() { await Indexing.createCardCollection();
const pokemon = await db.query.cards.findMany({ await Indexing.createSkuCollection();
with: { set: true, tcgdata: true, prices: true }, await Indexing.upsertCardCollection(db);
}); await Indexing.upsertSkuCollection(db);
await ClosePool();
// Ensure the collection exists before importing documents console.log(chalk.green('Pokémon reindex complete.'));
await createCollection(client);
await client.collections('cards').documents().import(pokemon.map(card => ({
id: card.cardId.toString(),
cardId: card.cardId,
productId: card.productId,
variant: card.variant,
productName: card.productName,
productLineName: card.productLineName,
rarityName: card.rarityName,
setName: card.set?.setName || "",
cardType: card.cardType || "",
energyType: card.energyType || "",
number: card.number,
Artist: card.artist || "",
sealed: card.sealed,
content: [card.productName,card.productLineName,card.set?.setName || "",card.number,card.rarityName,card.artist || ""].join(' '),
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
sku_id: card.prices.map(price => price.skuId.toString())
})), { action: 'upsert' });
const skus = await db.query.skus.findMany({
with: { card: true }
});
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
//card_id: sku.card?.cardId.toString()
})));
console.log(chalk.green('Search index preloaded with Pokémon cards.'));
}
await preloadSearchIndex().catch((error) => {
console.error(chalk.red('Error preloading search index:'), error);
for (const e of error.importResults) {
if (!e.success) {
console.error(chalk.red(`Error importing document ${e.id}:`), e.error);
}
}
process.exit(1);
}).finally(() => {
ClosePool();
console.log(chalk.blue('Database connection closed.'));
process.exit(0);
});

View File

@@ -3,16 +3,11 @@ import 'dotenv/config';
import chalk from 'chalk'; import chalk from 'chalk';
import { db, ClosePool } from '../src/db/index.ts'; import { db, ClosePool } from '../src/db/index.ts';
import { sql, inArray, eq } from 'drizzle-orm'; import { sql, inArray, eq } from 'drizzle-orm';
import { skus, processingSkus } from '../src/db/schema.ts'; import { skus, processingSkus, priceHistory } from '../src/db/schema.ts';
import { client } from '../src/db/typesense.ts';
import { toSnakeCase } from 'drizzle-orm/casing'; import { toSnakeCase } from 'drizzle-orm/casing';
import * as Indexing from './indexing.ts';
const DollarToInt = (dollar: any) => {
if (dollar === null) return null;
return Math.round(dollar * 100);
}
function sleep(ms: number) { function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@@ -60,6 +55,15 @@ async function syncPrices() {
console.error(chalk.yellow(`Expected ${batchSize} SKUs, got ${skuData.length}`)); console.error(chalk.yellow(`Expected ${batchSize} SKUs, got ${skuData.length}`));
} }
if (skuData.length === 0) {
console.error(chalk.red('0 SKUs, skipping DB updates.'));
// remove skus from the 'working' processingSkus table
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
// be nice to the API and not send too many requests in a short time
await sleep(200);
continue;
}
const skuUpdates = skuData.map((sku: any) => { return { const skuUpdates = skuData.map((sku: any) => { return {
skuId: sku.skuId, skuId: sku.skuId,
cardId: 0, cardId: 0,
@@ -73,15 +77,33 @@ async function syncPrices() {
marketPrice: sku.marketPrice, marketPrice: sku.marketPrice,
priceCount: null, priceCount: null,
}}); }});
await db.insert(skus).values(skuUpdates).onConflictDoUpdate({ const skuRows = await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
target: skus.skuId, target: skus.skuId,
set: { set: {
calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`), calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`),
highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`), highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`),
lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.name)}`), lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.name)}`),
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`), marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
},
setWhere: sql`skus.market_price is distinct from excluded.market_price`,
}).returning();
if (skuRows && skuRows.length > 0) {
const skuHistory = skuRows.filter(row => row.calculatedAt != null).map(row => { return {
skuId: row.skuId,
calculatedAt: new Date(row.calculatedAt?.toISOString().slice(0, 10)||0),
marketPrice: row.marketPrice,
}});
if (skuHistory && skuHistory.length > 0) {
await db.insert(priceHistory).values(skuHistory).onConflictDoUpdate({
target: [priceHistory.skuId,priceHistory.calculatedAt],
set: {
marketPrice: sql.raw(`excluded.${toSnakeCase(skus.marketPrice.name)}`),
}
});
console.log(chalk.cyan(`${skuRows.length} history rows added.`));
} }
}); }
// remove skus from the 'working' processingSkus table // remove skus from the 'working' processingSkus table
await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds)); await db.delete(processingSkus).where(inArray(processingSkus.skuId, skuIds));
@@ -92,22 +114,10 @@ async function syncPrices() {
} }
async function indexPrices() {
const skus = await db.query.skus.findMany();
await client.collections('skus').documents().import(skus.map(sku => ({
id: sku.skuId.toString(),
condition: sku.condition,
highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice),
})), { action: 'upsert' });
}
const start = Date.now(); const start = Date.now();
await syncPrices(); await syncPrices();
await indexPrices(); await Indexing.upsertSkuCollection(db);
await ClosePool(); await ClosePool();
const end = Date.now(); const end = Date.now();
const duration = (end - start) / 1000; const duration = (end - start) / 1000;

View File

@@ -41,7 +41,7 @@
// @import 'bootstrap/scss/spinners'; // @import 'bootstrap/scss/spinners';
@import 'bootstrap/scss/tables'; @import 'bootstrap/scss/tables';
@import 'bootstrap/scss/toasts'; @import 'bootstrap/scss/toasts';
// @import 'bootstrap/scss/tooltip'; @import 'bootstrap/scss/tooltip';
@import 'bootstrap/scss/transitions'; @import 'bootstrap/scss/transitions';
// Optional helpers // Optional helpers

View File

@@ -380,6 +380,20 @@ $tiers: (
drop-shadow(0 4px 6px rgba(0, 0, 0, 0.2)); drop-shadow(0 4px 6px rgba(0, 0, 0, 0.2));
} }
.tooltip.volatility-popover .tooltip-inner {
background: #1d1f21;
color: #e9ecef;
padding: 0.9rem 1rem;
border-radius: 0.6rem;
text-align: left;
max-width: 260px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.tooltip.volatility-popover .tooltip-arrow::before {
border-top-color: #1d1f21 !important;
}
/* -------------------------------------------------- /* --------------------------------------------------
Pricing Pricing
-------------------------------------------------- */ -------------------------------------------------- */

View File

@@ -1,6 +1,6 @@
import * as bootstrap from 'bootstrap'; import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap; window.bootstrap = bootstrap;
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
// trap browser back and close the modal if open // trap browser back and close the modal if open
const cardModal = document.getElementById('cardModal'); const cardModal = document.getElementById('cardModal');
@@ -24,4 +24,29 @@ cardModal.addEventListener('hide.bs.modal', () => {
if (history.state && history.state.modalOpen) { if (history.state && history.state.modalOpen) {
history.back(); history.back();
} }
}); });
import { Tooltip } from "bootstrap";
// Initialize all tooltips globally
const initTooltips = () => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
if (!el._tooltipInstance) {
el._tooltipInstance = new Tooltip(el, {
container: 'body', // ensures tooltip is appended to body, important for modals
});
}
});
};
// Run on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTooltips);
} else {
initTooltips();
}
// Optional: observe DOM changes for dynamically added tooltips (e.g., modals loaded later)
const observer = new MutationObserver(() => initTooltips());
observer.observe(document.body, { childList: true, subtree: true });

234
src/assets/js/priceChart.js Normal file
View File

@@ -0,0 +1,234 @@
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);
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);
}
// 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 : 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);
// 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');
}
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);
if (!hasData || !activeConditionHasData) {
setEmptyState(true);
return;
}
setEmptyState(false);
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();

View File

@@ -16,18 +16,8 @@ import BackToTop from "./BackToTop.astro"
</div> </div>
<div class="col-sm-12 col-md-10 mt-0"> <div class="col-sm-12 col-md-10 mt-0">
<div class="d-flex flex-row align-items-center mb-2"> <div class="d-flex flex-row align-items-center mb-2">
<div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small d-none"> <div id="sortBy"></div>
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button> <div id="totalResults"></div>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#">Price: High to Low</a></li>
<li><a class="dropdown-item" href="#">Price: Low to High</a></li>
<li><a class="dropdown-item" href="#">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item" href="#">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item" href="#">Card Number: Ascending</a></li>
<li><a class="dropdown-item" href="#">Card Number: Descending</a></li>
</ul>
<div id="totalResults"></div>
</div>
<div id="activeFilters"></div> <div id="activeFilters"></div>
</div> </div>
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div> <div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
@@ -41,7 +31,6 @@ import BackToTop from "./BackToTop.astro"
</div> </div>
</div> </div>
<!-- Modal nav buttons, rendered outside modal-content so they survive htmx swaps -->
<button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card"> <button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/> <path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
@@ -58,7 +47,110 @@ import BackToTop from "./BackToTop.astro"
<script is:inline> <script is:inline>
(function () { (function () {
// ── State ──────────────────────────────────────────────────────────────── // ── Sort dropdown ─────────────────────────────────────────────────────────
// Plain JS toggle — no dependency on Bootstrap's Dropdown JS initialising.
// Uses event delegation so it works after OOB swaps repopulate #sortBy.
document.addEventListener('click', (e) => {
const sortBy = document.getElementById('sortBy');
// Toggle the menu when the button is clicked
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
if (btn) {
e.preventDefault();
e.stopPropagation();
const menu = btn.nextElementSibling;
menu.classList.toggle('show');
btn.setAttribute('aria-expanded', menu.classList.contains('show'));
return;
}
// Handle sort option selection
const opt = e.target.closest('#sortBy .sort-option');
if (opt) {
e.preventDefault();
const menu = opt.closest('.dropdown-menu');
const btn2 = menu?.previousElementSibling;
menu?.classList.remove('show');
if (btn2) btn2.setAttribute('aria-expanded', 'false');
const sortInput = document.getElementById('sortInput');
if (sortInput) sortInput.value = opt.dataset.sort;
document.getElementById('sortLabel').textContent = opt.dataset.label;
document.querySelectorAll('.sort-option').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
return;
}
// Click outside — close any open sort menu
const menu = document.querySelector('#sortBy .dropdown-menu.show');
if (menu) {
menu.classList.remove('show');
const btn3 = menu.previousElementSibling;
if (btn3) btn3.setAttribute('aria-expanded', 'false');
}
});
// ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
if (navigator.clipboard && navigator.clipboard.write) {
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754');
} else {
const url = img.src;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
showCopyToast('📋 Image URL copied!', '#198754');
} else {
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showCopyToast('📋 Image URL copied!', '#198754');
}
}
} catch (err) {
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
};
function showCopyToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px;
border-radius: 8px; font-size: 14px; z-index: 9999;
opacity: 0; transition: opacity 0.2s ease;
pointer-events: none;
`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 2000);
}
// ── State ─────────────────────────────────────────────────────────────────
const cardIndex = []; const cardIndex = [];
let currentCardId = null; let currentCardId = null;
let isNavigating = false; let isNavigating = false;
@@ -120,6 +212,15 @@ import BackToTop from "./BackToTop.astro"
} }
} }
// ── Fire card-modal:swapped so the partial's script can init the chart ────
function initChartAfterSwap(modal) {
const canvas = modal.querySelector('#priceHistoryChart');
if (!canvas) return;
requestAnimationFrame(() => {
modal.dispatchEvent(new CustomEvent('card-modal:swapped', { bubbles: false }));
});
}
async function loadCard(cardId, direction = null) { async function loadCard(cardId, direction = null) {
if (!cardId || isNavigating) return; if (!cardId || isNavigating) return;
isNavigating = true; isNavigating = true;
@@ -130,16 +231,18 @@ import BackToTop from "./BackToTop.astro"
const url = `/partials/card-modal?cardId=${cardId}`; const url = `/partials/card-modal?cardId=${cardId}`;
const { idx, total } = getAdjacentIds(); const { idx, total } = getAdjacentIds();
if (idx >= total - 3) { if (idx >= total - 3) tryTriggerSentinel();
tryTriggerSentinel();
}
const doSwap = async () => { const doSwap = async () => {
const response = await fetch(url); const response = await fetch(url);
const html = await response.text(); const html = await response.text();
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
modal.innerHTML = html; modal.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(modal); if (typeof htmx !== 'undefined') htmx.process(modal);
updateNavButtons(modal); updateNavButtons(modal);
initChartAfterSwap(modal);
}; };
if (document.startViewTransition && direction) { if (document.startViewTransition && direction) {
@@ -153,9 +256,7 @@ import BackToTop from "./BackToTop.astro"
isNavigating = false; isNavigating = false;
const { idx: newIdx, total: newTotal } = getAdjacentIds(); const { idx: newIdx, total: newTotal } = getAdjacentIds();
if (newIdx >= newTotal - 3) { if (newIdx >= newTotal - 3) tryTriggerSentinel();
tryTriggerSentinel();
}
} }
function navigatePrev() { function navigatePrev() {
@@ -198,7 +299,7 @@ import BackToTop from "./BackToTop.astro"
else navigatePrev(); else navigatePrev();
}, { passive: true }); }, { passive: true });
// ── Hook into HTMX card-modal opens ────────────────────────────────────── // ── HTMX card-modal opens ─────────────────────────────────────────────────
document.body.addEventListener('htmx:beforeRequest', async (e) => { document.body.addEventListener('htmx:beforeRequest', async (e) => {
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return; if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
@@ -213,24 +314,29 @@ import BackToTop from "./BackToTop.astro"
const target = document.getElementById('cardModal'); const target = document.getElementById('cardModal');
const sourceImg = cardEl?.querySelector('img'); const sourceImg = cardEl?.querySelector('img');
// ── Fetch first, THEN transition ──────────────────────────────────────
const response = await fetch(url, { headers: { 'HX-Request': 'true' } }); const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`); if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
const html = await response.text(); const html = await response.text();
const transitionName = `card-hero-${currentCardId}`;
try { try {
if (sourceImg) { if (sourceImg) {
sourceImg.style.viewTransitionName = 'card-hero'; sourceImg.style.viewTransitionName = transitionName;
sourceImg.style.opacity = '0'; // hide original immediately after capture sourceImg.style.opacity = '0';
} }
const transition = document.startViewTransition(async () => { const transition = document.startViewTransition(async () => {
if (sourceImg) sourceImg.style.viewTransitionName = '';
if (target._reconnectChartObserver) target._reconnectChartObserver();
target.innerHTML = html; target.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(target); if (typeof htmx !== 'undefined') htmx.process(target);
const destImg = target.querySelector('img.card-image'); const destImg = target.querySelector('img.card-image');
if (destImg) { if (destImg) {
destImg.style.viewTransitionName = 'card-hero'; destImg.style.viewTransitionName = transitionName;
if (!destImg.complete) { if (!destImg.complete) {
await new Promise(resolve => { await new Promise(resolve => {
destImg.addEventListener('load', resolve, { once: true }); destImg.addEventListener('load', resolve, { once: true });
@@ -242,6 +348,7 @@ import BackToTop from "./BackToTop.astro"
await transition.finished; await transition.finished;
updateNavButtons(target); updateNavButtons(target);
initChartAfterSwap(target);
} catch (err) { } catch (err) {
console.error('[card-modal] transition failed:', err); console.error('[card-modal] transition failed:', err);
@@ -249,17 +356,18 @@ import BackToTop from "./BackToTop.astro"
} finally { } finally {
if (sourceImg) { if (sourceImg) {
sourceImg.style.viewTransitionName = ''; sourceImg.style.viewTransitionName = '';
sourceImg.style.opacity = ''; // restore after transition sourceImg.style.opacity = '';
} }
const destImg = target.querySelector('img.card-image'); const destImg = target.querySelector('img.card-image');
if (destImg) destImg.style.viewTransitionName = ''; if (destImg) destImg.style.viewTransitionName = '';
} }
}); });
// ── Show/hide nav buttons with Bootstrap modal events ──────────────────── // ── Bootstrap modal events ────────────────────────────────────────────────
const cardModal = document.getElementById('cardModal'); const cardModal = document.getElementById('cardModal');
cardModal.addEventListener('shown.bs.modal', () => { cardModal.addEventListener('shown.bs.modal', () => {
updateNavButtons(cardModal); updateNavButtons(cardModal);
initChartAfterSwap(cardModal);
}); });
cardModal.addEventListener('hidden.bs.modal', () => { cardModal.addEventListener('hidden.bs.modal', () => {
currentCardId = null; currentCardId = null;

View File

@@ -30,11 +30,11 @@ import { Show } from '@clerk/astro/components'
<a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter"><span class="d-block d-md-none filter-icon mt-1"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a> <a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter"><span class="d-block d-md-none filter-icon mt-1"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
<div class="input-group"> <div class="input-group">
<input type="hidden" name="start" id="start" value="0" /> <input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." /> <input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });"> <button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg> <svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
</button> </button>
</div> </div>
</form> </form>
</Show> </Show>

View File

@@ -17,6 +17,7 @@ pool.on('error', (err) => {
}); });
export const db = drizzle({ client: pool, relations: relations, casing: 'snake_case' }); export const db = drizzle({ client: pool, relations: relations, casing: 'snake_case' });
export type DBInstance = typeof db;
export const ClosePool = () => { export const ClosePool = () => {
pool.end(); pool.end();

View File

@@ -8,12 +8,19 @@ export const relations = defineRelations(schema, (r) => ({
to: r.skus.skuId, to: r.skus.skuId,
}), }),
}, },
salesHistory: {
sku: r.one.skus({
from: r.salesHistory.skuId,
to: r.skus.skuId,
}),
},
skus: { skus: {
card: r.one.cards({ card: r.one.cards({
from: [r.skus.productId, r.skus.variant], from: [r.skus.productId, r.skus.variant],
to: [r.cards.productId, r.cards.variant], to: [r.cards.productId, r.cards.variant],
}), }),
history: r.many.priceHistory(), history: r.many.priceHistory(),
latestSales: r.many.salesHistory(),
}, },
cards: { cards: {
prices: r.many.skus(), prices: r.many.skus(),

View File

@@ -1,5 +1,5 @@
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core" //import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema } from "drizzle-orm/pg-core"; import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
export const pokeSchema = pgSchema("pokemon"); export const pokeSchema = pgSchema("pokemon");
@@ -101,12 +101,27 @@ export const skus = pokeSchema.table('skus', {
]); ]);
export const priceHistory = pokeSchema.table('price_history', { export const priceHistory = pokeSchema.table('price_history', {
skuId: integer().default(0).notNull(), skuId: integer().notNull(),
calculatedAt: timestamp(), calculatedAt: timestamp().notNull(),
marketPrice: decimal({ precision: 10, scale: 2 }), marketPrice: decimal({ precision: 10, scale: 2 }),
}, },
(table) => [ (table) => [
index('idx_price_history').on(table.skuId, table.calculatedAt), primaryKey({ name: 'pk_price_history', columns: [table.skuId, table.calculatedAt] })
]);
export const salesHistory = pokeSchema.table('sales_history',{
skuId: integer().notNull(),
orderDate: timestamp().notNull(),
title: varchar({ length: 255 }),
customListingId: varchar({ length: 255 }),
language: varchar({ length: 100 }),
listingType: varchar({ length: 100 }),
purchasePrice: decimal({ precision: 10, scale: 2 }),
quantity: integer(),
shippingPrice: decimal({ precision: 10, scale: 2 })
},
(table) => [
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
]); ]);
export const processingSkus = pokeSchema.table('processing_skus', { export const processingSkus = pokeSchema.table('processing_skus', {

View File

@@ -38,7 +38,8 @@ const { title } = Astro.props;
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="../assets/js/main.js"></script> <script src="../assets/js/main.js"></script>
<script>import '../assets/js/priceChart.js';</script>
</body> </body>
</html> </html>

View File

@@ -6,9 +6,6 @@ import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import pokedexList from '../data/pokedex.json'; import pokedexList from '../data/pokedex.json';
const searchParams = Astro.url.searchParams;
const query = searchParams.get('q') || '*';
// Get random # (00011025) // Get random # (00011025)
const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0"); const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0");
@@ -34,7 +31,7 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
</p> </p>
</div> </div>
<div class="col-12 col-md-5 offset-md-1"> <div class="col-12 col-md-5 offset-md-1">
<div class="alert alert-warning border p-2" role="alert"> <div id="reveal-hint" class="alert alert-warning border p-2" role="alert">
<h4 class="alert-heading">Who's that Pokémon?</h4> <h4 class="alert-heading">Who's that Pokémon?</h4>
<p class="mb-0">Click the image to reveal.</p> <p class="mb-0">Click the image to reveal.</p>
</div> </div>
@@ -47,12 +44,14 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png" alt="" /> <img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png" alt="" />
<img <img
class="m-auto position-absolute w-75 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle" class="m-auto position-absolute w-75 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle pokemon-clickable"
src={pokedexImage} src={pokedexImage}
alt={pokemonName} alt=""
data-name={pokemonName} data-name={pokemonName}
role="button" role="button"
tabindex="0" tabindex="0"
draggable="false"
aria-label="Reveal the Pokémon"
/> />
</div> </div>
</div> </div>
@@ -60,21 +59,97 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
<!-- Pokémon name reveal --> <!-- Pokémon name reveal -->
<div class="col-12 text-center mt-3"> <div class="col-12 text-center mt-3">
<h3 id="pokemon-name" class="opacity-0 transition-opacity">???</h3> <h3
id="pokemon-name"
class="opacity-0 pokemon-transition"
aria-live="polite"
aria-atomic="true"
>???</h3>
<button
id="play-again"
class="btn btn-primary mt-3 opacity-0 pokemon-transition"
style="pointer-events: none;"
aria-hidden="true"
>
Guess another Pokémon
</button>
</div> </div>
</div> </div>
</div>
<Footer slot="footer" />
</Layout>
<style>
.pokemon-transition {
transition: opacity 0.4s ease;
}
.pokemon-clickable {
cursor: pointer;
}
.pokemon-clickable:focus-visible {
outline: 3px solid #ffc107;
outline-offset: 4px;
border-radius: 4px;
}
@keyframes pokemon-pulse {
0%, 100% { filter: brightness(0) drop-shadow(0 0 6px var(--bs-info-border-subtle)); }
50% { filter: brightness(0) drop-shadow(0 0 18px var(--bs-info)); }
}
.masked-image {
filter: brightness(0);
animation: pokemon-pulse 2s ease-in-out infinite;
}
</style>
<script> <script>
const img = document.querySelector('.masked-image') as HTMLImageElement | null; const img = document.querySelector('.masked-image') as HTMLImageElement | null;
const nameEl = document.querySelector('#pokemon-name'); const nameEl = document.querySelector('#pokemon-name');
const playAgainBtn = document.querySelector('#play-again') as HTMLButtonElement | null;
const hintEl = document.querySelector('#reveal-hint');
function revealPokemon() { function revealPokemon() {
if (!img || !nameEl) return; if (!img || !nameEl) return;
const doReveal = () => { const doReveal = () => {
img.classList.remove('masked-image'); // Remove masked styles and interactivity from image
nameEl.textContent = img.dataset.name || "Unknown Pokémon"; img.classList.remove('masked-image', 'pokemon-clickable');
img.removeAttribute('role');
img.removeAttribute('tabindex');
img.removeAttribute('aria-label');
img.style.animation = '';
// Update alt text now that it's revealed
img.alt = img.dataset.name || 'Unknown Pokémon';
// Reveal name
nameEl.textContent = img.dataset.name || 'Unknown Pokémon';
nameEl.classList.remove('opacity-0'); nameEl.classList.remove('opacity-0');
dataLayer.push({ event: '404reveal', pokemonName: img.dataset.name });
// Update hint text
if (hintEl) {
hintEl.querySelector('p')!.textContent = "It's " + (img.dataset.name || 'Unknown Pokémon') + "!";
}
// Show play again button
if (playAgainBtn) {
playAgainBtn.classList.remove('opacity-0');
playAgainBtn.style.pointerEvents = '';
playAgainBtn.removeAttribute('aria-hidden');
}
// Fire analytics safely
try {
if (typeof dataLayer !== 'undefined') {
dataLayer.push({ event: '404reveal', pokemonName: img.dataset.name });
}
} catch (e) {
// Analytics unavailable, continue silently
}
}; };
if (!document.startViewTransition) { if (!document.startViewTransition) {
@@ -98,9 +173,8 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
revealPokemon(); revealPokemon();
} }
}); });
</script>
</div>
<Footer slot="footer" /> playAgainBtn?.addEventListener('click', () => {
</Layout> window.location.reload();
});
</script>

View File

@@ -4,16 +4,18 @@ import SetIcon from '../../components/SetIcon.astro';
import EnergyIcon from '../../components/EnergyIcon.astro'; import EnergyIcon from '../../components/EnergyIcon.astro';
import RarityIcon from '../../components/RarityIcon.astro'; import RarityIcon from '../../components/RarityIcon.astro';
import { db } from '../../db/index'; import { db } from '../../db/index';
import { privateDecrypt } from "node:crypto"; import { priceHistory, skus } from '../../db/schema';
import { eq, inArray } from 'drizzle-orm';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro"; import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
import { Tooltip } from "bootstrap";
export const partial = true; export const partial = true;
export const prerender = false; export const prerender = false;
const searchParams = Astro.url.searchParams; const searchParams = Astro.url.searchParams;
const cardId = Number(searchParams.get('cardId')) || 0; 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({ const card = await db.query.cards.findFirst({
where: { cardId: Number(cardId) }, where: { cardId: Number(cardId) },
with: { with: {
@@ -22,21 +24,9 @@ const card = await db.query.cards.findFirst({
} }
}); });
// 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) { function timeAgo(date: Date | null) {
if (!date) return "Not applicable"; if (!date) return "Not applicable";
const seconds = Math.floor((Date.now() - date.getTime()) / 1000); const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
const intervals: Record<string, number> = { const intervals: Record<string, number> = {
year: 31536000, year: 31536000,
month: 2592000, month: 2592000,
@@ -44,67 +34,130 @@ function timeAgo(date: Date | null) {
hour: 3600, hour: 3600,
minute: 60 minute: 60
}; };
for (const [unit, value] of Object.entries(intervals)) { for (const [unit, value] of Object.entries(intervals)) {
const count = Math.floor(seconds / value); const count = Math.floor(seconds / value);
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`; if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
} }
return "just now"; return "just now";
} }
// Get the most recent calculatedAt across all prices
const calculatedAt = (() => { const calculatedAt = (() => {
if (!card?.prices?.length) return null; if (!card?.prices?.length) return null;
// Extract all valid calculatedAt timestamps
const dates = card.prices const dates = card.prices
.map(p => p.calculatedAt) .map(p => p.calculatedAt)
.filter(d => d) // remove null/undefined .filter(d => d)
.map(d => new Date(d)); .map(d => new Date(d));
if (!dates.length) return null; if (!dates.length) return null;
// Return the most recent one
return new Date(Math.max(...dates.map(d => d.getTime()))); return new Date(Math.max(...dates.map(d => d.getTime())));
})(); })();
// ── Fetch price history + compute volatility ──────────────────────────────
const cardSkus = card?.prices?.length
? await db.select().from(skus).where(eq(skus.cardId, cardId))
: [];
const skuIds = cardSkus.map(s => s.skuId);
const historyRows = skuIds.length
? 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)
: [];
// Rolling 30-day cutoff for volatility calculation
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const byCondition: Record<string, number[]> = {};
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<string, { label: string; monthlyVol: number }> = {};
for (const [condition, prices] of Object.entries(byCondition)) {
volatilityByCondition[condition] = computeVolatility(prices);
}
// ── Price history for chart (full history, not windowed) ──────────────────
const priceHistoryForChart = historyRows.map(row => ({
condition: row.condition,
calculatedAt: row.calculatedAt
? new Date(row.calculatedAt).toISOString().split('T')[0]
: null,
marketPrice: row.marketPrice,
})).filter(r => r.calculatedAt !== null);
// ── Determine which range buttons to show ────────────────────────────────
const now = Date.now();
const oldestDate = historyRows.length
? Math.min(...historyRows
.filter(r => r.calculatedAt)
.map(r => new Date(r.calculatedAt!).getTime()))
: now;
const dataSpanDays = (now - oldestDate) / 86_400_000;
const showRanges = {
'1m': dataSpanDays >= 1,
'3m': dataSpanDays >= 60,
'6m': dataSpanDays >= 180,
'1y': dataSpanDays >= 365,
'all': dataSpanDays >= 400,
};
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const conditionAttributes = (price: any) => { const conditionAttributes = (price: any) => {
const volatility = (() => { const condition: string = price?.condition || "Near Mint";
const market = price?.marketPrice; const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
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";
})();
const volatilityClass = (() => { const volatilityClass = (() => {
switch (volatility) { switch (vol.label) {
case "High": return "alert-danger"; case "High": return "alert-danger";
case "Medium": return "alert-warning"; case "Medium": return "alert-warning";
case "Low": return "alert-success"; case "Low": return "alert-success";
default: return "alert-dark"; // Indeterminate default: return "alert-dark";
} }
})(); })();
const condition: string = price?.condition || "Near Mint"; const volatilityDisplay = vol.label === '—'
? '—'
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
return { return {
"Near Mint": { label: "nav-nm", volatility, volatilityClass, class: "show active" }, "Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
"Lightly Played": { label: "nav-lp", volatility, volatilityClass }, "Lightly Played": { label: "nav-lp", volatility: volatilityDisplay, volatilityClass },
"Moderately Played": { label: "nav-mp", volatility, volatilityClass }, "Moderately Played":{ label: "nav-mp", volatility: volatilityDisplay, volatilityClass },
"Heavily Played": { label: "nav-hp", volatility, volatilityClass }, "Heavily Played": { label: "nav-hp", volatility: volatilityDisplay, volatilityClass },
"Damaged": { label: "nav-dmg", volatility, volatilityClass } "Damaged": { label: "nav-dmg", volatility: volatilityDisplay, volatilityClass }
}[condition]; }[condition];
}; };
@@ -121,166 +174,199 @@ const altSearchUrl = (card: any) => {
<div class="modal-content" data-card-id={card?.cardId}> <div class="modal-content" data-card-id={card?.cardId}>
<div class="modal-header border-0"> <div class="modal-header border-0">
<div class="container-fluid row align-items-center"> <div class="container-fluid row align-items-center">
<div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div> <div class="h4 card-title pe-2 col-sm-12 col-md-auto mb-sm-1">{card?.productName}</div>
<div class="text-secondary col-auto">{card?.number}</div> <div class="text-secondary col-auto">{card?.number}</div>
<div class="text-light col-auto">{card?.variant}</div> <div class="text-light col-auto">{card?.variant}</div>
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div> </div>
</div> </div>
<div class="modal-body pt-0"> <div class="modal-body pt-0">
<div class="container-fluid"> <div class="container-fluid">
<div class="card mb-2 border-0"> <div class="card mb-2 border-0">
<div class="row g-4"> <div class="row g-4">
<div class="col-sm-12 col-md-3">
<div class="position-relative mt-1"><img src={`/cards/${card?.productId}.jpg`} class="card-image w-100 img-fluid rounded-4" alt={card?.productName} onerror="this.onerror=null;this.src='/cards/default.jpg'" onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"><span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span><span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span><span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span><span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span></div>
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary">{card?.set?.setCode}</div>
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.artist}</div>
</div>
</div>
<div class="col-sm-12 col-md-7">
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true"><span class="d-none d-lg-inline">Near Mint</span><span class="d-lg-none">NM</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false"><span class="d-none d-lg-inline">Lightly Played</span><span class="d-lg-none">LP</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false"><span class="d-none d-lg-inline">Moderately Played</span><span class="d-lg-none">MP</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false"><span class="d-none d-lg-inline">Heavily Played</span><span class="d-lg-none">HP</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false"><span class="d-none d-lg-inline">Damaged</span><span class="d-lg-none">DMG</span></button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false"><span class="d-none d-lg-inline">Inventory</span><span class="d-lg-none">+/-</span></button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
const attributes = conditionAttributes(price);
return (
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-block gap-1 d-lg-flex">
<div class="d-flex flex-row flex-lg-column gap-1 col-12 col-lg-2 mb-1">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Lowest Price</h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Highest Price</h6>
<p class="mb-0 mt-1">${price.highestPrice}</p>
</div>
<div class={`alert alert-secondary rounded p-2 flex-fill d-flex flex-column mb-1 ${attributes?.volatilityClass}`}>
<h6 class="mb-auto">Volatility</h6>
<p class="mb-0 mt-1">{attributes?.volatility}</p>
</div>
</div>
<div class="d-flex flex-column gap-1 col-12 col-lg-10 mb-0 me-2 clearfix">
<div class="alert alert-dark rounded p-2 mb-1 table-responsive">
<h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption>
<thead class="table-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-dark rounded p-2 mb-1">
<h6>Market Price History</h6>
<div class="position-relative" style="height: 200px;">
<canvas id={`priceChart-${price.priceId}`} class="price-history-chart" data-card-id={card?.cardId} data-condition={price.condition}></canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
);
})}
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-2 mt-2">
</div> <!-- Card image column -->
</div> <div class="col-sm-12 col-md-3">
</div> <div class="position-relative mt-1">
<img
src={`/cards/${card?.productId}.jpg`}
class="card-image w-100 img-fluid rounded-4"
alt={card?.productName}
onerror="this.onerror=null;this.src='/cards/default.jpg'"
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
/>
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
<span class="position-absolute bottom-0 start-0 d-inline"><SetIcon set={card?.set?.setCode} /></span>
<span class="position-absolute top-0 end-0 d-inline"><EnergyIcon energy={card?.energyType} /></span>
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
</div> </div>
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column"> <div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary">{card?.set?.setCode}</div>
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.artist}</div>
</div>
</div>
<!-- Tabs + price data column -->
<div class="col-sm-12 col-md-7">
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true">
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false">
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false">
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false">
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false">
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
{card?.prices.slice().sort((a, b) => conditionOrder.indexOf(a.condition) - conditionOrder.indexOf(b.condition)).map((price) => {
const attributes = conditionAttributes(price);
return (
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class ?? ''}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-flex flex-column gap-1">
<!-- Stat cards -->
<div class="d-flex flex-fill flex-row gap-1">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Lowest Price</h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p>
</div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Highest Price</h6>
<p class="mb-0 mt-1">${price.highestPrice}</p>
</div>
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
<h6 class="mb-auto d-flex justify-content-between align-items-start">
<span>Volatility</span>
<span
class="volatility-info"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-container="body"
data-bs-custom-class="volatility-popover"
data-bs-trigger="hover focus click"
data-bs-html="true"
data-bs-title={`
<div class='tooltip-heading fw-bold mb-1'>Monthly Volatility</div>
<div class='small'>
<p class="mb-1">
<strong>What this measures:</strong> how much the market price tends to move day-to-day,
scaled up to a monthly expectation.
</p>
<p class="mb-0">
A card with <strong>30% volatility</strong> typically swings ±30% over a month.
</p>
</div>
`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16"> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> <path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/> </svg>
</span>
</h6>
<p class="mb-0 mt-1">{attributes?.volatility}</p>
</div>
</div>
<!-- Table only — chart is outside the tab panes -->
<div class="w-100">
<div class="alert alert-dark rounded p-2 mb-0 table-responsive">
<h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption>
<thead class="table-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
<tr><td> </td><td> </td><td> </td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
})}
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
</div>
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
<div class="d-block d-lg-flex gap-1 mt-1">
<div class="col-12">
<div class="alert alert-dark rounded p-2 mb-0">
<h6>Market Price History</h6>
<div id="priceHistoryEmpty" class="d-none text-secondary text-center py-4">
No sales data for the selected period/condition
</div>
<div class="position-relative" style="height: 200px;">
<canvas
id="priceHistoryChart"
class="price-history-chart"
data-card-id={card?.cardId}
data-history={JSON.stringify(priceHistoryForChart)}>
</canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
{showRanges['1m'] && <button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>}
{showRanges['3m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>}
{showRanges['6m'] && <button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>}
{showRanges['1y'] && <button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>}
{showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
</div>
</div>
</div>
</div>
</div>
<!-- External links column -->
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column">
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a> <a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a> <a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a> <a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
</div> </div>
</div> </div>
<div class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div> <div class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
</div> </div>
</div>
</div>
</div> </div>
</div>
</div> </div>
</div>
<script is:inline>
async function copyImage(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754');
} catch (err) {
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
}
function showCopyToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px;
border-radius: 8px; font-size: 14px; z-index: 9999;
opacity: 0; transition: opacity 0.2s ease;
pointer-events: none;
`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 2000);
}
</script>

View File

@@ -17,13 +17,31 @@ const facetFields:any = {
"energyType": "Energy Type" "energyType": "Energy Type"
} }
// ── Allowed sort values ───────────────────────────────────────────────────
// Maps the client-supplied key to the actual Typesense sort_by string.
// Never pass raw user input directly to sort_by.
// Note: price sorting uses nmMarketPrice — a field you need to denormalize
// onto your card document in your Typesense indexing step (NM market price
// as an integer in cents, e.g. nmMarketPrice: 499 = $4.99).
const sortMap: Record<string, string> = {
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
'marketPrice:desc': 'marketPrice:desc,releaseDate:desc,number:asc',
'marketPrice:asc': 'marketPrice:asc,releaseDate:desc,number:asc',
'number:asc': '_text_match:asc,number:asc',
'number:desc': '_text_match:asc,number:desc',
};
const DEFAULT_SORT = '_text_match:asc,releaseDate:desc,number:asc';
// get the query from post request using form data // get the query from post request using form data
const formData = await Astro.request.formData(); const formData = await Astro.request.formData();
const query = formData.get('q')?.toString() || ''; const query = formData.get('q')?.toString() || '';
const start = Number(formData.get('start')?.toString() || '0'); const start = Number(formData.get('start')?.toString() || '0');
const sortKey = formData.get('sort')?.toString() || '';
const resolvedSort = sortMap[sortKey] ?? DEFAULT_SORT;
const filters = Array.from(formData.entries()) const filters = Array.from(formData.entries())
.filter(([key, value]) => key !== 'q' && key !== 'start') .filter(([key, value]) => key !== 'q' && key !== 'start' && key !== 'sort')
.reduce((acc, [key, value]) => { .reduce((acc, [key, value]) => {
if (!acc[key]) { if (!acc[key]) {
acc[key] = []; acc[key] = [];
@@ -37,13 +55,13 @@ const filterChecked = (field: string, value: string) => {
}; };
const filterBy = Object.entries(filters).map(([field, values]) => { const filterBy = Object.entries(filters).map(([field, values]) => {
return `${field}:=[${values.join(',')}]`; return `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`;
}).join(' && '); }).join(' && ');
const facetFilter = (facet:string) => { const facetFilter = (facet:string) => {
const otherFilters = Object.entries(filters) const otherFilters = Object.entries(filters)
.filter(([field]) => field !== facet) .filter(([field]) => field !== facet)
.map(([field, values]) => `${field}:=[${values.join(',')}]`) .map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
.join(' && '); .join(' && ');
return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`; return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`;
}; };
@@ -57,7 +75,7 @@ let searchArray = [{
facet_by: '', facet_by: '',
max_facet_values: 0, max_facet_values: 0,
page: Math.floor(start / 20) + 1, page: Math.floor(start / 20) + 1,
sort_by: '_text_match:asc, releaseDate:desc, number:asc', sort_by: resolvedSort,
include_fields: '$skus(*)', include_fields: '$skus(*)',
}]; }];
@@ -80,7 +98,6 @@ if (start === 0) {
const searchRequests = { searches: searchArray }; const searchRequests = { searches: searchArray };
const commonSearchParams = { const commonSearchParams = {
q: query, q: query,
// query_by: 'productLineName,productName,setName,number,rarityName,Artist',
query_by: 'content' query_by: 'content'
}; };
@@ -88,10 +105,6 @@ const commonSearchParams = {
const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams); const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams);
const cardResults = searchResults.results[0] as any; const cardResults = searchResults.results[0] as any;
//console.log(util.inspect(cardResults.hits.map((c:any) => { return { productLineName:c.document.productLineName, productName:c.document.productName, setName:c.document.setName, number:c.document.number, rarityName:c.document.rarityName, Artist:c.document.Artist, text_match:c.text_match, text_match_info:c.text_match_info }; }), { showHidden: true, depth: null }));
//console.log(cardResults);
const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? []; const pokemon = cardResults.hits?.map((hit: any) => hit.document) ?? [];
const totalHits = cardResults?.found; const totalHits = cardResults?.found;
@@ -165,16 +178,30 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div> </div>
))} ))}
</div> </div>
<div id="totalResults d-none" class="ms-5 text-secondary" hx-swap-oob="true"> <div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small" hx-swap-oob="true">
<div class="dropdown">
<button class="btn btn-sm btn-dark dropdown-toggle small" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item sort-option small" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
<li><a class="dropdown-item sort-option small" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
</ul>
</div>
<span id="sortLabel" class="ms-2 text-secondary">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
</div>
<div id="totalResults" class="mb-2 ms-5 text-secondary small" hx-swap-oob="true">
{totalHits} {totalHits === 1 ? ' result' : ' results'} {totalHits} {totalHits === 1 ? ' result' : ' results'}
</div> </div>
<div id="activeFilters" class="d-flex align-items-center small ms-auto" hx-swap-oob="true"> <div id="activeFilters" class="mb-2 d-flex align-items-center small ms-auto" hx-swap-oob="true">
{(Object.entries(filters).length > 0) && {(Object.entries(filters).length > 0) &&
<span class="me-1">Filtered by:</span> <span class="me-1 small">Filtered by:</span>
<ul class="list-group list-group-horizontal"> <ul class="list-group list-group-horizontal">
{Object.entries(filters).map(([filter, values]) => ( {Object.entries(filters).map(([filter, values]) => (
values.map((value) => ( values.map((value) => (
<li data-facet={filter} data-value={value} class="list-group-item remove-filter">{value}</li> <li data-facet={filter} data-value={value} class="list-group-item small remove-filter">{value}</li>
)) ))
))} ))}
</ul> </ul>

View File

@@ -0,0 +1,71 @@
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;
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);
const historyRows = 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);
// Rolling 30-day cutoff for volatility
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const byCondition: Record<string, number[]> = {};
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<string, { label: string; monthlyVol: number }> = {};
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' }
});
};