Compare commits
15 Commits
c622c8bd8f
...
feat/csv-p
| Author | SHA1 | Date | |
|---|---|---|---|
| c0120e3e77 | |||
| 660da7cded | |||
| 2a17654c74 | |||
|
|
ee9f7a2561 | ||
|
|
2f17912949 | ||
| a86dc08b50 | |||
|
|
c4ebbfb060 | ||
|
|
9c81a13c69 | ||
| 3a6dbf2ed9 | |||
| e1ab59a2eb | |||
|
|
a8df9c71ee | ||
|
|
835a174da2 | ||
| 485f26de7b | |||
| c10e34cc34 | |||
| d9995e5e10 |
129
package-lock.json
generated
129
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"csv": "^6.4.1",
|
||||
"dotenv": "^17.2.4",
|
||||
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
||||
"pg": "^8.20.0",
|
||||
@@ -2613,16 +2614,6 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"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": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
@@ -3080,6 +3071,39 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3165,16 +3189,6 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -4342,16 +4356,6 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -5072,22 +5062,6 @@
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -6014,19 +5988,6 @@
|
||||
"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": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||
@@ -7090,22 +7051,6 @@
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
@@ -7131,6 +7076,12 @@
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"csv": "^6.4.1",
|
||||
"dotenv": "^17.2.4",
|
||||
"drizzle-orm": "^1.0.0-beta.15-859cf75",
|
||||
"pg": "^8.20.0",
|
||||
|
||||
87
scripts/csvprices.ts
Normal file
87
scripts/csvprices.ts
Normal 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
104
scripts/indexing.ts
Normal 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.'));
|
||||
}
|
||||
30
scripts/list-missing-images.ts
Normal file
30
scripts/list-missing-images.ts
Normal 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();
|
||||
147
scripts/preload-pricehistory.ts
Normal file
147
scripts/preload-pricehistory.ts
Normal 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 {};
|
||||
@@ -1,134 +1,11 @@
|
||||
import { Client } from 'typesense';
|
||||
import chalk from 'chalk';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import { client } from '../src/db/typesense.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' },
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
import * as Indexing from './indexing.ts';
|
||||
|
||||
|
||||
async function preloadSearchIndex() {
|
||||
const pokemon = await db.query.cards.findMany({
|
||||
with: { set: true, tcgdata: true, prices: true },
|
||||
});
|
||||
|
||||
// Ensure the collection exists before importing documents
|
||||
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);
|
||||
});
|
||||
await Indexing.createCardCollection();
|
||||
await Indexing.createSkuCollection();
|
||||
await Indexing.upsertCardCollection(db);
|
||||
await Indexing.upsertSkuCollection(db);
|
||||
await ClosePool();
|
||||
console.log(chalk.green('Pokémon reindex complete.'));
|
||||
|
||||
@@ -3,16 +3,11 @@ import 'dotenv/config';
|
||||
import chalk from 'chalk';
|
||||
import { db, ClosePool } from '../src/db/index.ts';
|
||||
import { sql, inArray, eq } from 'drizzle-orm';
|
||||
import { skus, processingSkus } from '../src/db/schema.ts';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
import { skus, processingSkus, priceHistory } from '../src/db/schema.ts';
|
||||
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) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -60,6 +55,15 @@ async function syncPrices() {
|
||||
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 {
|
||||
skuId: sku.skuId,
|
||||
cardId: 0,
|
||||
@@ -73,15 +77,33 @@ async function syncPrices() {
|
||||
marketPrice: sku.marketPrice,
|
||||
priceCount: null,
|
||||
}});
|
||||
await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
|
||||
const skuRows = await db.insert(skus).values(skuUpdates).onConflictDoUpdate({
|
||||
target: skus.skuId,
|
||||
set: {
|
||||
calculatedAt: sql.raw(`excluded.${toSnakeCase(skus.calculatedAt.name)}`),
|
||||
highestPrice: sql.raw(`excluded.${toSnakeCase(skus.highestPrice.name)}`),
|
||||
lowestPrice: sql.raw(`excluded.${toSnakeCase(skus.lowestPrice.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
|
||||
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();
|
||||
await syncPrices();
|
||||
await indexPrices();
|
||||
await Indexing.upsertSkuCollection(db);
|
||||
await ClosePool();
|
||||
const end = Date.now();
|
||||
const duration = (end - start) / 1000;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
// @import 'bootstrap/scss/spinners';
|
||||
@import 'bootstrap/scss/tables';
|
||||
@import 'bootstrap/scss/toasts';
|
||||
// @import 'bootstrap/scss/tooltip';
|
||||
@import 'bootstrap/scss/tooltip';
|
||||
@import 'bootstrap/scss/transitions';
|
||||
|
||||
// Optional helpers
|
||||
|
||||
@@ -380,6 +380,20 @@ $tiers: (
|
||||
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
|
||||
-------------------------------------------------- */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as bootstrap from 'bootstrap';
|
||||
window.bootstrap = bootstrap;
|
||||
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
|
||||
// trap browser back and close the modal if open
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
@@ -25,3 +25,28 @@ cardModal.addEventListener('hide.bs.modal', () => {
|
||||
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
234
src/assets/js/priceChart.js
Normal 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();
|
||||
@@ -16,18 +16,8 @@ import BackToTop from "./BackToTop.astro"
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-10 mt-0">
|
||||
<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">
|
||||
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
|
||||
<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="sortBy"></div>
|
||||
<div id="totalResults"></div>
|
||||
<div id="activeFilters"></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>
|
||||
@@ -41,7 +31,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
</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">
|
||||
<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"/>
|
||||
@@ -58,7 +47,110 @@ import BackToTop from "./BackToTop.astro"
|
||||
<script is:inline>
|
||||
(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 = [];
|
||||
let currentCardId = null;
|
||||
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) {
|
||||
if (!cardId || isNavigating) return;
|
||||
isNavigating = true;
|
||||
@@ -130,16 +231,18 @@ import BackToTop from "./BackToTop.astro"
|
||||
const url = `/partials/card-modal?cardId=${cardId}`;
|
||||
|
||||
const { idx, total } = getAdjacentIds();
|
||||
if (idx >= total - 3) {
|
||||
tryTriggerSentinel();
|
||||
}
|
||||
if (idx >= total - 3) tryTriggerSentinel();
|
||||
|
||||
const doSwap = async () => {
|
||||
const response = await fetch(url);
|
||||
const html = await response.text();
|
||||
|
||||
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
||||
|
||||
modal.innerHTML = html;
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
updateNavButtons(modal);
|
||||
initChartAfterSwap(modal);
|
||||
};
|
||||
|
||||
if (document.startViewTransition && direction) {
|
||||
@@ -153,9 +256,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
isNavigating = false;
|
||||
|
||||
const { idx: newIdx, total: newTotal } = getAdjacentIds();
|
||||
if (newIdx >= newTotal - 3) {
|
||||
tryTriggerSentinel();
|
||||
}
|
||||
if (newIdx >= newTotal - 3) tryTriggerSentinel();
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
@@ -198,7 +299,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
else navigatePrev();
|
||||
}, { passive: true });
|
||||
|
||||
// ── Hook into HTMX card-modal opens ──────────────────────────────────────
|
||||
// ── HTMX card-modal opens ─────────────────────────────────────────────────
|
||||
document.body.addEventListener('htmx:beforeRequest', async (e) => {
|
||||
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
|
||||
|
||||
@@ -213,24 +314,29 @@ import BackToTop from "./BackToTop.astro"
|
||||
const target = document.getElementById('cardModal');
|
||||
const sourceImg = cardEl?.querySelector('img');
|
||||
|
||||
// ── Fetch first, THEN transition ──────────────────────────────────────
|
||||
const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
|
||||
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
|
||||
const html = await response.text();
|
||||
|
||||
const transitionName = `card-hero-${currentCardId}`;
|
||||
|
||||
try {
|
||||
if (sourceImg) {
|
||||
sourceImg.style.viewTransitionName = 'card-hero';
|
||||
sourceImg.style.opacity = '0'; // hide original immediately after capture
|
||||
sourceImg.style.viewTransitionName = transitionName;
|
||||
sourceImg.style.opacity = '0';
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(async () => {
|
||||
if (sourceImg) sourceImg.style.viewTransitionName = '';
|
||||
|
||||
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
||||
|
||||
target.innerHTML = html;
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) {
|
||||
destImg.style.viewTransitionName = 'card-hero';
|
||||
destImg.style.viewTransitionName = transitionName;
|
||||
if (!destImg.complete) {
|
||||
await new Promise(resolve => {
|
||||
destImg.addEventListener('load', resolve, { once: true });
|
||||
@@ -242,6 +348,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
await transition.finished;
|
||||
updateNavButtons(target);
|
||||
initChartAfterSwap(target);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[card-modal] transition failed:', err);
|
||||
@@ -249,17 +356,18 @@ import BackToTop from "./BackToTop.astro"
|
||||
} finally {
|
||||
if (sourceImg) {
|
||||
sourceImg.style.viewTransitionName = '';
|
||||
sourceImg.style.opacity = ''; // restore after transition
|
||||
sourceImg.style.opacity = '';
|
||||
}
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) destImg.style.viewTransitionName = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Show/hide nav buttons with Bootstrap modal events ────────────────────
|
||||
// ── Bootstrap modal events ────────────────────────────────────────────────
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
});
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
|
||||
@@ -30,6 +30,7 @@ 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>
|
||||
<div class="input-group">
|
||||
<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..." />
|
||||
<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>
|
||||
@@ -37,4 +38,3 @@ import { Show } from '@clerk/astro/components'
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ pool.on('error', (err) => {
|
||||
});
|
||||
|
||||
export const db = drizzle({ client: pool, relations: relations, casing: 'snake_case' });
|
||||
export type DBInstance = typeof db;
|
||||
|
||||
export const ClosePool = () => {
|
||||
pool.end();
|
||||
|
||||
@@ -8,12 +8,19 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
to: r.skus.skuId,
|
||||
}),
|
||||
},
|
||||
salesHistory: {
|
||||
sku: r.one.skus({
|
||||
from: r.salesHistory.skuId,
|
||||
to: r.skus.skuId,
|
||||
}),
|
||||
},
|
||||
skus: {
|
||||
card: r.one.cards({
|
||||
from: [r.skus.productId, r.skus.variant],
|
||||
to: [r.cards.productId, r.cards.variant],
|
||||
}),
|
||||
history: r.many.priceHistory(),
|
||||
latestSales: r.many.salesHistory(),
|
||||
},
|
||||
cards: {
|
||||
prices: r.many.skus(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//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");
|
||||
|
||||
@@ -101,12 +101,27 @@ export const skus = pokeSchema.table('skus', {
|
||||
]);
|
||||
|
||||
export const priceHistory = pokeSchema.table('price_history', {
|
||||
skuId: integer().default(0).notNull(),
|
||||
calculatedAt: timestamp(),
|
||||
skuId: integer().notNull(),
|
||||
calculatedAt: timestamp().notNull(),
|
||||
marketPrice: decimal({ precision: 10, scale: 2 }),
|
||||
},
|
||||
(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', {
|
||||
|
||||
@@ -38,7 +38,8 @@ const { title } = Astro.props;
|
||||
</div>
|
||||
|
||||
<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>import '../assets/js/priceChart.js';</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,9 +6,6 @@ import NavBar from '../components/NavBar.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import pokedexList from '../data/pokedex.json';
|
||||
|
||||
const searchParams = Astro.url.searchParams;
|
||||
const query = searchParams.get('q') || '*';
|
||||
|
||||
// Get random # (0001–1025)
|
||||
const randomNumber = String(Math.floor(Math.random() * 1025) + 1).padStart(4, "0");
|
||||
|
||||
@@ -34,7 +31,7 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="mb-0">Click the image to reveal.</p>
|
||||
</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="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}
|
||||
alt={pokemonName}
|
||||
alt=""
|
||||
data-name={pokemonName}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="false"
|
||||
aria-label="Reveal the Pokémon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,21 +59,97 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
||||
|
||||
<!-- Pokémon name reveal -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
const img = document.querySelector('.masked-image') as HTMLImageElement | null;
|
||||
const nameEl = document.querySelector('#pokemon-name');
|
||||
const playAgainBtn = document.querySelector('#play-again') as HTMLButtonElement | null;
|
||||
const hintEl = document.querySelector('#reveal-hint');
|
||||
|
||||
function revealPokemon() {
|
||||
if (!img || !nameEl) return;
|
||||
|
||||
const doReveal = () => {
|
||||
img.classList.remove('masked-image');
|
||||
nameEl.textContent = img.dataset.name || "Unknown Pokémon";
|
||||
// Remove masked styles and interactivity from image
|
||||
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');
|
||||
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) {
|
||||
@@ -98,9 +173,8 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
|
||||
revealPokemon();
|
||||
}
|
||||
});
|
||||
|
||||
playAgainBtn?.addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
</div>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</Layout>
|
||||
@@ -4,16 +4,18 @@ import SetIcon from '../../components/SetIcon.astro';
|
||||
import EnergyIcon from '../../components/EnergyIcon.astro';
|
||||
import RarityIcon from '../../components/RarityIcon.astro';
|
||||
import { db } from '../../db/index';
|
||||
import { privateDecrypt } from "node:crypto";
|
||||
import { priceHistory, skus } from '../../db/schema';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
|
||||
import { Tooltip } from "bootstrap";
|
||||
|
||||
export const partial = true;
|
||||
export const prerender = false;
|
||||
|
||||
const searchParams = Astro.url.searchParams;
|
||||
const cardId = Number(searchParams.get('cardId')) || 0;
|
||||
|
||||
// query the database for the card with the given productId and return the card data as json
|
||||
const card = await db.query.cards.findFirst({
|
||||
where: { cardId: Number(cardId) },
|
||||
with: {
|
||||
@@ -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) {
|
||||
if (!date) return "Not applicable";
|
||||
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
||||
const intervals: Record<string, number> = {
|
||||
year: 31536000,
|
||||
month: 2592000,
|
||||
@@ -44,67 +34,130 @@ function timeAgo(date: Date | null) {
|
||||
hour: 3600,
|
||||
minute: 60
|
||||
};
|
||||
|
||||
for (const [unit, value] of Object.entries(intervals)) {
|
||||
const count = Math.floor(seconds / value);
|
||||
if (count >= 1) return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
return "just now";
|
||||
}
|
||||
|
||||
// Get the most recent calculatedAt across all prices
|
||||
const calculatedAt = (() => {
|
||||
if (!card?.prices?.length) return null;
|
||||
|
||||
// Extract all valid calculatedAt timestamps
|
||||
const dates = card.prices
|
||||
.map(p => p.calculatedAt)
|
||||
.filter(d => d) // remove null/undefined
|
||||
.filter(d => d)
|
||||
.map(d => new Date(d));
|
||||
|
||||
if (!dates.length) return null;
|
||||
|
||||
// Return the most recent one
|
||||
return new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
})();
|
||||
|
||||
// ── 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 conditionAttributes = (price: any) => {
|
||||
const volatility = (() => {
|
||||
const market = price?.marketPrice;
|
||||
const low = price?.lowestPrice;
|
||||
const high = price?.highestPrice;
|
||||
|
||||
if (market == null || low == null || high == null || Number(market) === 0) {
|
||||
return "Indeterminate";
|
||||
}
|
||||
|
||||
const spreadPct = (Number(high) - Number(low)) / Number(market) * 100;
|
||||
|
||||
if (spreadPct >= 81) return "High";
|
||||
if (spreadPct >= 59) return "Medium";
|
||||
return "Low";
|
||||
})();
|
||||
const condition: string = price?.condition || "Near Mint";
|
||||
const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
|
||||
|
||||
const volatilityClass = (() => {
|
||||
switch (volatility) {
|
||||
case "High": return "alert-danger";
|
||||
case "Medium": return "alert-warning";
|
||||
case "Low": return "alert-success";
|
||||
default: return "alert-dark"; // Indeterminate
|
||||
switch (vol.label) {
|
||||
case "High": return "alert-danger";
|
||||
case "Medium": return "alert-warning";
|
||||
case "Low": return "alert-success";
|
||||
default: return "alert-dark";
|
||||
}
|
||||
})();
|
||||
|
||||
const condition: string = price?.condition || "Near Mint";
|
||||
const volatilityDisplay = vol.label === '—'
|
||||
? '—'
|
||||
: `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
|
||||
|
||||
return {
|
||||
"Near Mint": { label: "nav-nm", volatility, volatilityClass, class: "show active" },
|
||||
"Lightly Played": { label: "nav-lp", volatility, volatilityClass },
|
||||
"Moderately Played": { label: "nav-mp", volatility, volatilityClass },
|
||||
"Heavily Played": { label: "nav-hp", volatility, volatilityClass },
|
||||
"Damaged": { label: "nav-dmg", volatility, volatilityClass }
|
||||
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
|
||||
"Lightly Played": { label: "nav-lp", volatility: volatilityDisplay, volatilityClass },
|
||||
"Moderately Played":{ label: "nav-mp", volatility: volatilityDisplay, volatilityClass },
|
||||
"Heavily Played": { label: "nav-hp", volatility: volatilityDisplay, volatilityClass },
|
||||
"Damaged": { label: "nav-dmg", volatility: volatilityDisplay, volatilityClass }
|
||||
}[condition];
|
||||
};
|
||||
|
||||
@@ -121,166 +174,199 @@ const altSearchUrl = (card: any) => {
|
||||
<div class="modal-content" data-card-id={card?.cardId}>
|
||||
<div class="modal-header border-0">
|
||||
<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="text-secondary col-auto">{card?.number}</div>
|
||||
<div class="text-light col-auto">{card?.variant}</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-light col-auto">{card?.variant}</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="modal-body pt-0">
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card image column -->
|
||||
<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="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={`${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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
@@ -17,13 +17,31 @@ const facetFields:any = {
|
||||
"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
|
||||
const formData = await Astro.request.formData();
|
||||
const query = formData.get('q')?.toString() || '';
|
||||
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())
|
||||
.filter(([key, value]) => key !== 'q' && key !== 'start')
|
||||
.filter(([key, value]) => key !== 'q' && key !== 'start' && key !== 'sort')
|
||||
.reduce((acc, [key, value]) => {
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
@@ -37,13 +55,13 @@ const filterChecked = (field: string, value: string) => {
|
||||
};
|
||||
|
||||
const filterBy = Object.entries(filters).map(([field, values]) => {
|
||||
return `${field}:=[${values.join(',')}]`;
|
||||
return `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`;
|
||||
}).join(' && ');
|
||||
|
||||
const facetFilter = (facet:string) => {
|
||||
const otherFilters = Object.entries(filters)
|
||||
.filter(([field]) => field !== facet)
|
||||
.map(([field, values]) => `${field}:=[${values.join(',')}]`)
|
||||
.map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
|
||||
.join(' && ');
|
||||
return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`;
|
||||
};
|
||||
@@ -57,7 +75,7 @@ let searchArray = [{
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
page: Math.floor(start / 20) + 1,
|
||||
sort_by: '_text_match:asc, releaseDate:desc, number:asc',
|
||||
sort_by: resolvedSort,
|
||||
include_fields: '$skus(*)',
|
||||
}];
|
||||
|
||||
@@ -80,7 +98,6 @@ if (start === 0) {
|
||||
const searchRequests = { searches: searchArray };
|
||||
const commonSearchParams = {
|
||||
q: query,
|
||||
// query_by: 'productLineName,productName,setName,number,rarityName,Artist',
|
||||
query_by: 'content'
|
||||
};
|
||||
|
||||
@@ -88,10 +105,6 @@ const commonSearchParams = {
|
||||
const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams);
|
||||
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 totalHits = cardResults?.found;
|
||||
|
||||
@@ -165,16 +178,30 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
</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'}
|
||||
</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) &&
|
||||
<span class="me-1">Filtered by:</span>
|
||||
<span class="me-1 small">Filtered by:</span>
|
||||
<ul class="list-group list-group-horizontal">
|
||||
{Object.entries(filters).map(([filter, values]) => (
|
||||
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>
|
||||
|
||||
71
src/pages/partials/price-history.ts
Normal file
71
src/pages/partials/price-history.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user