2 Commits

Author SHA1 Message Date
71c167308d [feat] dashboard shows inventory 2026-04-07 22:34:31 -04:00
cb829e1922 [chore] move inventory relationship to sku 2026-04-07 09:52:17 -04:00
9 changed files with 247 additions and 531 deletions

33
scripts/diagnose-join.ts Normal file
View File

@@ -0,0 +1,33 @@
import 'dotenv/config';
import chalk from 'chalk';
import util from 'node:util';
import { client } from '../src/db/typesense.ts';
const variants = [
'$skus(*, $cards(*))',
'$skus(*,$cards(*))',
'$skus(*, card_id, $cards(*))',
'$skus(*, $cards(*, strategy:nest))',
'$skus(*, $cards(*, strategy:merge))',
];
const debug = await client.debug.retrieve();
console.log(chalk.cyan(`Typesense server version: ${debug.version}`));
console.log();
for (const include of variants) {
console.log(chalk.yellow(`include_fields: ${include}`));
try {
const res: any = await client.collections('inventories').documents().search({
q: '*',
query_by: 'content',
per_page: 1,
include_fields: include,
});
const doc = res.hits?.[0]?.document;
console.log(util.inspect(doc, { depth: null, colors: false }));
} catch (err: any) {
console.log(chalk.red(` ERROR: ${err.message ?? err}`));
}
console.log();
}

View File

@@ -4,6 +4,8 @@ import type { DBInstance } from '../src/db/index.ts';
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import * as util from 'util';
const DollarToInt = (dollar: any) => { const DollarToInt = (dollar: any) => {
if (dollar === null) return null; if (dollar === null) return null;
@@ -62,7 +64,7 @@ export const createCardCollection = async () => {
{ name: 'releaseDate', type: 'int32' }, { name: 'releaseDate', type: 'int32' },
{ name: 'marketPrice', type: 'int32', optional: true, sort: true }, { name: 'marketPrice', type: 'int32', optional: true, sort: true },
{ name: 'content', type: 'string', token_separators: ['/'] }, { name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true } // { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
], ],
}); });
console.log(chalk.green('Collection "cards" created successfully.')); console.log(chalk.green('Collection "cards" created successfully.'));
@@ -83,6 +85,7 @@ export const createSkuCollection = async () => {
{ name: 'highestPrice', type: 'int32', optional: true }, { name: 'highestPrice', type: 'int32', optional: true },
{ name: 'lowestPrice', type: 'int32', optional: true }, { name: 'lowestPrice', type: 'int32', optional: true },
{ name: 'marketPrice', type: 'int32', optional: true }, { name: 'marketPrice', type: 'int32', optional: true },
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
] ]
}); });
console.log(chalk.green('Collection "skus" created successfully.')); console.log(chalk.green('Collection "skus" created successfully.'));
@@ -101,7 +104,15 @@ export const createInventoryCollection = async () => {
{ name: 'id', type: 'string' }, { name: 'id', type: 'string' },
{ name: 'userId', type: 'string' }, { name: 'userId', type: 'string' },
{ name: 'catalogName', type: 'string' }, { name: 'catalogName', type: 'string' },
{ name: 'card_id', type: 'string', reference: 'cards.id' }, { name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
{ name: 'sku_id', type: 'string', reference: 'skus.id', async_reference: true },
// content,setName,productLineName,rarityName,energyType,cardType from cards for searching
{ name: 'content', type: 'string', token_separators: ['/'] },
{ name: 'setName', type: 'string' },
{ name: 'productLineName', type: 'string' },
{ name: 'rarityName', type: 'string' },
{ name: 'energyType', type: 'string' },
{ name: 'cardType', type: 'string' },
] ]
}); });
console.log(chalk.green('Collection "inventories" created successfully.')); console.log(chalk.green('Collection "inventories" created successfully.'));
@@ -132,7 +143,7 @@ export const upsertCardCollection = async (db:DBInstance) => {
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '), 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, releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
...(marketPrice !== null && { marketPrice }), ...(marketPrice !== null && { marketPrice }),
sku_id: card.prices.map(price => price.skuId.toString()) // sku_id: card.prices.map(price => price.skuId.toString())
}; };
}), { action: 'upsert' }); }), { action: 'upsert' });
console.log(chalk.green('Collection "cards" indexed successfully.')); console.log(chalk.green('Collection "cards" indexed successfully.'));
@@ -146,17 +157,34 @@ export const upsertSkuCollection = async (db:DBInstance) => {
highestPrice: DollarToInt(sku.highestPrice), highestPrice: DollarToInt(sku.highestPrice),
lowestPrice: DollarToInt(sku.lowestPrice), lowestPrice: DollarToInt(sku.lowestPrice),
marketPrice: DollarToInt(sku.marketPrice), marketPrice: DollarToInt(sku.marketPrice),
})), { action: 'upsert' }); card_id: sku.cardId.toString(),
})), { action: 'upsert' });
console.log(chalk.green('Collection "skus" indexed successfully.')); console.log(chalk.green('Collection "skus" indexed successfully.'));
} }
export const upsertInventoryCollection = async (db:DBInstance) => { export const upsertInventoryCollection = async (db:DBInstance) => {
const inv = await db.query.inventory.findMany(); const inv = await db.query.inventory.findMany({
with: { sku: { with: { card: { with: { set: true } } } } }
});
await client.collections('inventories').documents().import(inv.map(i => ({ await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId, id: i.inventoryId,
userId: i.userId, userId: i.userId,
catalogName: i.catalogName, catalogName: i.catalogName,
card_id: i.cardId.toString(), card_id: i.sku?.cardId.toString(),
sku_id: i.skuId.toString(),
productLineName: i.sku?.card?.productLineName,
rarityName: i.sku?.card?.rarityName,
setName: i.sku?.card?.set?.setName || "",
cardType: i.sku?.card?.cardType || "",
energyType: i.sku?.card?.energyType || "",
content: [
i.sku?.card?.productName,
i.sku?.card?.productLineName,
i.sku?.card?.set?.setName || "",
i.sku?.card?.number,
i.sku?.card?.rarityName,
i.sku?.card?.artist || ""
].join(' '),
})), { action: 'upsert' }); })), { action: 'upsert' });
console.log(chalk.green('Collection "inventories" indexed successfully.')); console.log(chalk.green('Collection "inventories" indexed successfully.'));
} }

View File

@@ -3,12 +3,12 @@ import { db, ClosePool } from '../src/db/index.ts';
import * as Indexing from './pokemon-helper.ts'; import * as Indexing from './pokemon-helper.ts';
//await Indexing.createCardCollection(); // await Indexing.createCardCollection();
//await Indexing.createSkuCollection(); await Indexing.createSkuCollection();
await Indexing.createInventoryCollection(); await Indexing.createInventoryCollection();
//await Indexing.upsertCardCollection(db); // await Indexing.upsertCardCollection(db);
//await Indexing.upsertSkuCollection(db); await Indexing.upsertSkuCollection(db);
await Indexing.upsertInventoryCollection(db); await Indexing.upsertInventoryCollection(db);
await ClosePool(); await ClosePool();
console.log(chalk.green('Pokémon reindex complete.')); console.log(chalk.green('Pokémon reindex complete.'));

View File

@@ -24,18 +24,13 @@ export const relations = defineRelations(schema, (r) => ({
inventories: r.many.inventory(), inventories: r.many.inventory(),
}, },
inventory: { inventory: {
card: r.one.cards({
from: r.inventory.cardId,
to: r.cards.cardId,
}),
sku: r.one.skus({ sku: r.one.skus({
from: [r.inventory.cardId, r.inventory.condition], from: r.inventory.skuId,
to: [r.skus.cardId, r.skus.condition], to: r.skus.skuId,
}), }),
}, },
cards: { cards: {
prices: r.many.skus(), prices: r.many.skus(),
inventories: r.many.inventory(),
set: r.one.sets({ set: r.one.sets({
from: r.cards.setId, from: r.cards.setId,
to: r.sets.setId, to: r.sets.setId,

View File

@@ -129,15 +129,14 @@ export const inventory = pokeSchema.table('inventory',{
inventoryId: uuid().primaryKey().notNull().defaultRandom(), inventoryId: uuid().primaryKey().notNull().defaultRandom(),
userId: varchar({ length: 100 }).notNull(), userId: varchar({ length: 100 }).notNull(),
catalogName: varchar({ length: 100 }), catalogName: varchar({ length: 100 }),
cardId: integer().notNull(), skuId: integer().notNull(),
condition: varchar({ length: 255 }).notNull(),
quantity: integer(), quantity: integer(),
purchasePrice: decimal({ precision: 10, scale: 2 }), purchasePrice: decimal({ precision: 10, scale: 2 }),
note: varchar({ length:255 }), note: varchar({ length:255 }),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
}, },
(table) => [ (table) => [
index('idx_userid_cardid').on(table.userId, table.cardId) index('idx_userid_skuId').on(table.userId, table.skuId)
]); ]);
export const processingSkus = pokeSchema.table('processing_skus', { export const processingSkus = pokeSchema.table('processing_skus', {

View File

@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { db } from '../../db/index'; import { db } from '../../db/index';
import { inventory } from '../../db/schema'; import { inventory, priceHistory } from '../../db/schema';
import { client } from '../../db/typesense'; import { client } from '../../db/typesense';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -15,27 +15,29 @@ const GainLoss = (purchasePrice: any, marketPrice: any) => {
const getInventory = async (userId: string, cardId: number) => { const getInventory = async (userId: string, cardId: number) => {
const inventories = await db.query.inventory.findMany({ const card = await db.query.cards.findFirst({
where: { userId:userId, cardId:cardId, }, where: { cardId: cardId, },
with: { card: true, sku: true, } with : { prices: {
with: { inventories: { where: { userId: userId } }, }
}, },
}); });
const invHtml = inventories.map(inv => { const invHtml = card?.prices?.flatMap(price => price.inventories.map(inv => {
const marketPrice = inv.sku?.marketPrice; const marketPrice = price.marketPrice;
const marketPriceDisplay = marketPrice ? `$${marketPrice}` : '—'; const marketPriceDisplay = marketPrice ? `$${marketPrice}` : '—';
const purchasePriceDisplay = inv.purchasePrice ? `$${Number(inv.purchasePrice).toFixed(2)}` : '—'; const purchasePriceDisplay = inv.purchasePrice ? `$${Number(inv.purchasePrice).toFixed(2)}` : '—';
return ` return `
<article class="border rounded-4 p-2 inventory-entry-card" <article class="border rounded-4 p-2 inventory-entry-card"
data-inventory-id="${inv.inventoryId}" data-inventory-id="${inv.inventoryId}"
data-card-id="${inv.cardId}" data-card-id="${price.cardId}"
data-purchase-price="${inv.purchasePrice}" data-purchase-price="${inv.purchasePrice}"
data-note="${(inv.note || '').replace(/"/g, '&quot;')}"> data-note="${(inv.note || '').replace(/"/g, '&quot;')}">
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<!-- Top row --> <!-- Top row -->
<div class="d-flex justify-content-between gap-3"> <div class="d-flex justify-content-between gap-3">
<div class="min-w-0 flex-grow-1"> <div class="min-w-0 flex-grow-1">
<div class="fw-semibold fs-6 text-body mb-1">${inv.condition}</div> <div class="fw-semibold fs-6 text-body mb-1">${price.condition}</div>
</div> </div>
<div class="fs-7 text-secondary">Added: ${inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</div> <div class="fs-7 text-secondary">Added: ${inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</div>
</div> </div>
@@ -71,7 +73,7 @@ const getInventory = async (userId: string, cardId: number) => {
</div> </div>
</div> </div>
</article>`; </article>`;
}); })) || [];
return new Response( return new Response(
invHtml.join(''), invHtml.join(''),
@@ -83,24 +85,47 @@ const getInventory = async (userId: string, cardId: number) => {
} }
const addToInventory = async (userId: string, cardId: number, condition: string, variant: string, purchasePrice: number, quantity: number, note: string, catalogName: string) => { const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
// First add to database // First add to database
const inv = await db.insert(inventory).values({ const inv = await db.insert(inventory).values({
userId: userId, userId: userId,
cardId: cardId, skuId: skuId,
catalogName: catalogName, catalogName: catalogName,
condition: condition,
purchasePrice: purchasePrice.toFixed(2), purchasePrice: purchasePrice.toFixed(2),
quantity: quantity, quantity: quantity,
note: note, note: note,
}).returning(); }).returning();
// Get card details from the database to add to Typesense
const card = await db.query.cards.findFirst({
where: { cardId: cardId },
with: { set: true },
});
try {
// And then add to Typesense for searching // And then add to Typesense for searching
await client.collections('inventories').documents().import(inv.map(i => ({ await client.collections('inventories').documents().import(inv.map(i => ({
id: i.inventoryId, id: i.inventoryId,
userId: i.userId, userId: i.userId,
catalogName: i.catalogName, catalogName: i.catalogName,
card_id: i.cardId.toString(), sku_id: i.skuId.toString(),
productLineName: card?.productLineName,
rarityName: card?.rarityName,
setName: card?.set?.setName || "",
cardType: card?.cardType || "",
energyType: card?.energyType || "",
card_id: card?.cardId.toString() || "",
content: [
card?.productName,
card?.productLineName,
card?.set?.setName || "",
card?.number,
card?.rarityName,
card?.artist || ""
].join(' '),
}))); })));
} catch (error) {
console.error('Error adding inventory to Typesense:', error);
}
} }
const removeFromInventory = async (inventoryId: string) => { const removeFromInventory = async (inventoryId: string) => {
@@ -128,13 +153,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
switch (action) { switch (action) {
case 'add': case 'add':
const condition = formData.get('condition')?.toString() || 'Unknown';
const variant = formData.get('variant')?.toString() || 'Normal';
const purchasePrice = Number(formData.get('purchasePrice')) || 0; const purchasePrice = Number(formData.get('purchasePrice')) || 0;
const quantity = Number(formData.get('quantity')) || 1; const quantity = Number(formData.get('quantity')) || 1;
const note = formData.get('note')?.toString() || ''; const note = formData.get('note')?.toString() || '';
const catalogName = formData.get('catalogName')?.toString() || 'Default'; const catalogName = formData.get('catalogName')?.toString() || 'Default';
await addToInventory(userId!, cardId, condition, variant, purchasePrice, quantity, note, catalogName); const condition = formData.get('condition')?.toString() || 'Near Mint';
const skuId = await db.query.skus.findFirst({
where: { cardId: cardId, condition: condition },
columns: { skuId: true },
}).then(sku => sku?.skuId);
if (!skuId) {
return new Response('SKU not found for card', { status: 404 });
}
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
break; break;
case 'remove': case 'remove':

View File

@@ -5,304 +5,14 @@ import NavItems from "../components/NavItems.astro";
import Footer from "../components/Footer.astro"; import Footer from "../components/Footer.astro";
import FirstEditionIcon from "../components/FirstEditionIcon.astro"; import FirstEditionIcon from "../components/FirstEditionIcon.astro";
// Mock inventory using the same schema as the Typesense cards collection.
// skus mirror the real shape: marketPrice is in cents (÷100 = dollars).
const inventory = [
{
productId: "42382",
productName: "Charizard",
setName: "Base Set",
setCode: "BS",
number: "4/102",
rarityName: "Rare Holo",
energyType: "Fire",
variant: "1st Edition",
qty: 2,
purchasePrice: 32000,
skus: [
{ condition: "Near Mint", marketPrice: 40000 },
{ condition: "Lightly Played", marketPrice: 31000 },
{ condition: "Moderately Played", marketPrice: 22000 },
{ condition: "Heavily Played", marketPrice: 14000 },
{ condition: "Damaged", marketPrice: 8500 },
],
},
{
productId: "146682",
productName: "Pikachu",
setName: "Shining Legends",
setCode: "SLG",
number: "SM70",
rarityName: "Promo",
energyType: "Lightning",
variant: "Normal",
qty: 5,
purchasePrice: 1500,
skus: [
{ condition: "Near Mint", marketPrice: 2000 },
{ condition: "Lightly Played", marketPrice: 1500 },
{ condition: "Moderately Played", marketPrice: 1100 },
{ condition: "Heavily Played", marketPrice: 700 },
{ condition: "Damaged", marketPrice: 400 },
],
},
{
productId: "246723",
productName: "Umbreon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "215/203",
rarityName: "Secret Rare",
energyType: "Darkness",
variant: "Alternate Art",
qty: 1,
purchasePrice: 8500,
skus: [
{ condition: "Near Mint", marketPrice: 11500 },
{ condition: "Lightly Played", marketPrice: 9000 },
{ condition: "Moderately Played", marketPrice: 6500 },
{ condition: "Heavily Played", marketPrice: 4000 },
{ condition: "Damaged", marketPrice: 2000 },
],
},
{
productId: "197660",
productName: "Gyarados",
setName: "Hidden Fates",
setCode: "HIF",
number: "SV19/SV94",
rarityName: "Shiny Holo Rare",
energyType: "Water",
variant: "Shiny",
qty: 3,
purchasePrice: 2500,
skus: [
{ condition: "Near Mint", marketPrice: 3000 },
{ condition: "Lightly Played", marketPrice: 2300 },
{ condition: "Moderately Played", marketPrice: 1600 },
{ condition: "Heavily Played", marketPrice: 900 },
{ condition: "Damaged", marketPrice: 500 },
],
},
{
productId: "246733",
productName: "Rayquaza VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "218/203",
rarityName: "Secret Rare",
energyType: "Dragon",
variant: "Alternate Art",
qty: 2,
purchasePrice: 6500,
skus: [
{ condition: "Near Mint", marketPrice: 8800 },
{ condition: "Lightly Played", marketPrice: 7000 },
{ condition: "Moderately Played", marketPrice: 5000 },
{ condition: "Heavily Played", marketPrice: 3200 },
{ condition: "Damaged", marketPrice: 1800 },
],
},
{
productId: "264218",
productName: "Eevee",
setName: "Sword & Shield",
setCode: "SSH",
number: "TG07/TG30",
rarityName: "Trainer Gallery",
energyType: "Colorless",
variant: "Normal",
qty: 10,
purchasePrice: 800,
skus: [
{ condition: "Near Mint", marketPrice: 900 },
{ condition: "Lightly Played", marketPrice: 700 },
{ condition: "Moderately Played", marketPrice: 500 },
{ condition: "Heavily Played", marketPrice: 300 },
{ condition: "Damaged", marketPrice: 150 },
],
},
{
productId: "451834",
productName: "Lugia V",
setName: "Silver Tempest",
setCode: "SIT",
number: "186/195",
rarityName: "Ultra Rare",
energyType: "Colorless",
variant: "Alternate Art",
qty: 1,
purchasePrice: 4500,
skus: [
{ condition: "Near Mint", marketPrice: 5800 },
{ condition: "Lightly Played", marketPrice: 4600 },
{ condition: "Moderately Played", marketPrice: 3200 },
{ condition: "Heavily Played", marketPrice: 2000 },
{ condition: "Damaged", marketPrice: 1000 },
],
},
{
productId: "106997",
productName: "Blastoise",
setName: "Base Set",
setCode: "BS",
number: "2/102",
rarityName: "Rare Holo",
energyType: "Water",
variant: "Shadowless",
qty: 1,
purchasePrice: 18000,
skus: [
{ condition: "Near Mint", marketPrice: 24000 },
{ condition: "Lightly Played", marketPrice: 18500 },
{ condition: "Moderately Played", marketPrice: 13000 },
{ condition: "Heavily Played", marketPrice: 8000 },
{ condition: "Damaged", marketPrice: 4500 },
],
},
{
productId: "253265",
productName: "Espeon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "205/203",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 2,
purchasePrice: 7000,
skus: [
{ condition: "Near Mint", marketPrice: 9200 },
{ condition: "Lightly Played", marketPrice: 7300 },
{ condition: "Moderately Played", marketPrice: 5200 },
{ condition: "Heavily Played", marketPrice: 3300 },
{ condition: "Damaged", marketPrice: 1600 },
],
},
{
productId: "253266",
productName: "Gengar VMAX",
setName: "Fusion Strike",
setCode: "FST",
number: "271/264",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 1,
purchasePrice: 5500,
skus: [
{ condition: "Near Mint", marketPrice: 4800 },
{ condition: "Lightly Played", marketPrice: 3800 },
{ condition: "Moderately Played", marketPrice: 2700 },
{ condition: "Heavily Played", marketPrice: 1700 },
{ condition: "Damaged", marketPrice: 900 },
],
},
{
productId: "226432",
productName: "Pikachu VMAX",
setName: "Vivid Voltage",
setCode: "VIV",
number: "188/185",
rarityName: "Secret Rare",
energyType: "Lightning",
variant: "Rainbow Rare",
qty: 3,
purchasePrice: 3200,
skus: [
{ condition: "Near Mint", marketPrice: 4100 },
{ condition: "Lightly Played", marketPrice: 3200 },
{ condition: "Moderately Played", marketPrice: 2300 },
{ condition: "Heavily Played", marketPrice: 1400 },
{ condition: "Damaged", marketPrice: 750 },
],
},
{
productId: "253275",
productName: "Mew VMAX",
setName: "Fusion Strike",
setCode: "FST",
number: "269/264",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 2,
purchasePrice: 4200,
skus: [
{ condition: "Near Mint", marketPrice: 5600 },
{ condition: "Lightly Played", marketPrice: 4400 },
{ condition: "Moderately Played", marketPrice: 3100 },
{ condition: "Heavily Played", marketPrice: 2000 },
{ condition: "Damaged", marketPrice: 1000 },
],
},
{
productId: "478077",
productName: "Darkrai VSTAR",
setName: "Astral Radiance",
setCode: "ASR",
number: "189/189",
rarityName: "Secret Rare",
energyType: "Darkness",
variant: "Gold",
qty: 1,
purchasePrice: 3800,
skus: [
{ condition: "Near Mint", marketPrice: 3200 },
{ condition: "Lightly Played", marketPrice: 2500 },
{ condition: "Moderately Played", marketPrice: 1800 },
{ condition: "Heavily Played", marketPrice: 1100 },
{ condition: "Damaged", marketPrice: 600 },
],
},
{
productId: "477060",
productName: "Leafeon VSTAR",
setName: "Pokémon GO",
setCode: "PGO",
number: "076/078",
rarityName: "Ultra Rare",
energyType: "Grass",
variant: "Normal",
qty: 4,
purchasePrice: 1200,
skus: [
{ condition: "Near Mint", marketPrice: 1800 },
{ condition: "Lightly Played", marketPrice: 1400 },
{ condition: "Moderately Played", marketPrice: 1000 },
{ condition: "Heavily Played", marketPrice: 600 },
{ condition: "Damaged", marketPrice: 300 },
],
},
{
productId: "478100",
productName: "Giratina VSTAR",
setName: "Lost Origin",
setCode: "LOR",
number: "196/196",
rarityName: "Secret Rare",
energyType: "Dragon",
variant: "Alternate Art",
qty: 1,
purchasePrice: 5200,
skus: [
{ condition: "Near Mint", marketPrice: 7800 },
{ condition: "Lightly Played", marketPrice: 6100 },
{ condition: "Moderately Played", marketPrice: 4400 },
{ condition: "Heavily Played", marketPrice: 2800 },
{ condition: "Damaged", marketPrice: 1400 },
],
},
];
// Helpers
const nmPrice = (card: typeof inventory[0]) => (card.skus[0]?.marketPrice ?? 0) / 100;
const nmPurchase = (card: typeof inventory[0]) => card.purchasePrice / 100;
const gain = (card: typeof inventory[0]) => nmPrice(card) - nmPurchase(card);
const totalQty = inventory.reduce((s, c) => s + c.qty, 0); // const totalQty = inventory.reduce((s, c) => s + c.qty, 0);
const totalValue = inventory.reduce((s, c) => s + nmPrice(c) * c.qty, 0); // const totalValue = inventory.reduce((s, c) => s + nmPrice(c) * c.qty, 0);
const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0); // const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
const totalQty = 1234;
const totalValue = 5678.90;
const totalGain = 1234.56;
--- ---
<Layout title="Inventory Dashboard"> <Layout title="Inventory Dashboard">
@@ -406,72 +116,10 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
</div> </div>
<div id="inventoryView"> <div id="inventoryView">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"> <div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
{inventory.map(card => {
const market = nmPrice(card);
const purchase = nmPurchase(card);
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<div class="col">
<article class="inv-grid-card">
<div
class="card-trigger position-relative inv-grid-media"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<div
class="rounded-4 card-image"
data-energy={card.energyType}
data-rarity={card.rarityName}
data-variant={card.variant}
data-name={card.productName}
>
<img
src={`static/cards/${card.productId}.jpg`}
alt={card.productName}
loading="lazy"
decoding="async"
class="img-fluid rounded-4 w-100"
onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')"
/>
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
<FirstEditionIcon edition={card.variant} />
</span>
</div>
</div>
<div class="d-flex flex-row justify-content-between my-1 align-items-center">
<input type="number" class="form-control text-center" style="max-width: 50%;" value="1" min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
<div class="" aria-label="Edit controls">
<button type="button" class="btn btn-outline-warning btn-sm"><svg class="edit-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M100.4 417.2C104.5 402.6 112.2 389.3 123 378.5L304.2 197.3L338.1 163.4C354.7 180 389.4 214.7 442.1 267.4L476 301.3L442.1 335.2L260.9 516.4C250.2 527.1 236.8 534.9 222.2 539L94.4 574.6C86.1 576.9 77.1 574.6 71 568.4C64.9 562.2 62.6 553.3 64.9 545L100.4 417.2zM156 413.5C151.6 418.2 148.4 423.9 146.7 430.1L122.6 517L209.5 492.9C215.9 491.1 221.7 487.8 226.5 483.2L155.9 413.5zM510 267.4C493.4 250.8 458.7 216.1 406 163.4L372 129.5C398.5 103 413.4 88.1 416.9 84.6C430.4 71 448.8 63.4 468 63.4C487.2 63.4 505.6 71 519.1 84.6L554.8 120.3C568.4 133.9 576 152.3 576 171.4C576 190.5 568.4 209 554.8 222.5C551.3 226 536.4 240.9 509.9 267.4z"/></svg></button>
<button type="button" class="btn btn-outline-danger btn-sm"><svg class="delete-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232.7 69.9L224 96L128 96C110.3 96 96 110.3 96 128C96 145.7 110.3 160 128 160L512 160C529.7 160 544 145.7 544 128C544 110.3 529.7 96 512 96L416 96L407.3 69.9C402.9 56.8 390.7 48 376.9 48L263.1 48C249.3 48 237.1 56.8 232.7 69.9zM512 208L128 208L149.1 531.1C150.7 556.4 171.7 576 197 576L443 576C468.3 576 489.3 556.4 490.9 531.1L512 208z"/></svg></button>
</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="p small text-secondary">{card.setName}</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="h5">{card.productName}</div>
</div>
<div class="d-flex flex-row mt-1 justify-content-between align-items-baseline">
<div class={`inv-grid-trend small ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow">{isGain ? "▲" : "▼"}</span>
<span class="h6 my-0">${market.toFixed(2)}</span>
</div>
<div class={`inv-grid-delta small ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)}</br>{isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%
</div>
</div>
</article>
</div>
);
})}
</div> </div>
<div id="tableView" style="display:none"> <!-- <div id="tableView" style="display:none">
<div class="inv-list-wrap"> <div class="inv-list-wrap">
<table class="table align-middle mb-0 inv-list-table"> <table class="table align-middle mb-0 inv-list-table">
<tbody id="inventoryRows"> <tbody id="inventoryRows">
@@ -544,12 +192,12 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
</table> </table>
</div> </div>
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div> <div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
</div> </div> -->
</div> </div>
</main> </main>
</div> </div>
<div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog"> <!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary"> <div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary"> <div class="modal-header border-secondary">
@@ -629,7 +277,7 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
</div> </div>
</div> </div>
</div> </div>
</div> </div> -->
<Footer slot="footer" /> <Footer slot="footer" />
</Layout> </Layout>
@@ -915,134 +563,3 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
} }
} }
</style> </style>
<script>
import * as bootstrap from "bootstrap";
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".modal").forEach((el) => {
bootstrap.Modal.getOrCreateInstance(el);
});
document.querySelectorAll("#catalogList [data-catalog]").forEach((item) => {
item.addEventListener("click", () => {
document.querySelectorAll("#catalogList [data-catalog]").forEach((i) => i.classList.remove("active"));
item.classList.add("active");
const catalog = item.dataset.catalog ?? "all";
document.querySelectorAll("#gridView .col").forEach((col) => {
col.style.display = catalog === "all" ? "" : "none";
});
document.querySelectorAll("#inventoryRows tr").forEach((row) => {
row.style.display = catalog === "all" ? "" : "none";
});
updateRowCount();
});
});
const gridView = document.getElementById("gridView");
const tableView = document.getElementById("tableView");
const btnGrid = document.getElementById("btnGrid");
const btnTable = document.getElementById("btnTable");
const rowCount = document.getElementById("rowCount");
const tbody = document.getElementById("inventoryRows");
function showGrid() {
gridView.style.display = "";
tableView.style.display = "none";
btnGrid.classList.add("active");
btnTable.classList.remove("active");
}
function showTable() {
gridView.style.display = "none";
tableView.style.display = "";
btnGrid.classList.remove("active");
btnTable.classList.add("active");
updateRowCount();
}
btnGrid?.addEventListener("click", showGrid);
btnTable?.addEventListener("click", showTable);
const searchInput = document.getElementById("inventorySearch");
const clearBtn = document.getElementById("clearSearch");
let searchTimer;
searchInput?.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const term = searchInput.value.toLowerCase();
clearBtn.classList.toggle("d-none", !term);
tbody?.querySelectorAll("tr").forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
});
updateRowCount();
}, 120);
});
clearBtn?.addEventListener("click", () => {
searchInput.value = "";
clearBtn.classList.add("d-none");
tbody?.querySelectorAll("tr").forEach((r) => {
r.style.display = "";
});
updateRowCount();
});
function updateRowCount() {
if (!rowCount || !tbody) return;
const visible = [...tbody.querySelectorAll("tr")].filter((r) => r.style.display !== "none").length;
rowCount.textContent = `Showing ${visible} card${visible !== 1 ? "s" : ""}`;
}
updateRowCount();
document.getElementById("createCatalogBtn")?.addEventListener("click", () => {
const input = document.getElementById("catalogNameInput");
const name = input.value.trim();
if (!name) {
input.focus();
return;
}
const li = document.createElement("li");
li.className =
"list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2";
li.setAttribute("data-catalog", name);
li.setAttribute("role", "button");
li.style.cursor = "pointer";
li.innerHTML = `<span class="text-secondary" style="font-size:.7rem">▸</span>${name}`;
li.addEventListener("click", () => {
document.querySelectorAll("#catalogList [data-catalog]").forEach((i) => i.classList.remove("active"));
li.classList.add("active");
});
document.getElementById("catalogList")?.appendChild(li);
input.value = "";
//bootstrap.Modal.getInstance(document.getElementById("newCatalogModal"))?.hide();
});
document.getElementById("csvFileInput")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const lines = ev.target.result.split("\n").slice(0, 4);
const preview = document.getElementById("csvPreviewContent");
preview.textContent = lines.join("\n");
document.getElementById("csvPreview")?.classList.remove("d-none");
};
reader.readAsText(file);
});
});
</script>

View File

@@ -102,7 +102,7 @@ const facetFilter = (facet:string) => {
// primary search values (for cards) // primary search values (for cards)
let searchArray = [{ let searchArray = [{
collection: 'cards', collection: 'cards',
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`, filter_by: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
per_page: 20, per_page: 20,
facet_by: '', facet_by: '',
max_facet_values: 0, max_facet_values: 0,
@@ -143,6 +143,7 @@ const totalHits = cardResults?.found;
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "" // format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns ""
const formatPrice = (condition:string, skus: any) => { const formatPrice = (condition:string, skus: any) => {
if (typeof skus === 'undefined' || skus.length === 0) return '—';
const sku:any = skus.find((price:any) => price.condition === condition); const sku:any = skus.find((price:any) => price.condition === condition);
if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—'; if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—';

View File

@@ -0,0 +1,112 @@
---
import { client } from '../../db/typesense';
import RarityIcon from '../../components/RarityIcon.astro';
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
export const prerender = false;
import * as util from 'util';
// 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 { userId } = Astro.locals.auth();
// primary search values (for cards)
let searchArray = [{
collection: 'inventories',
filter_by: `userId:=${userId} && $skus($cards(id:*))`,
per_page: 20,
facet_by: '',
max_facet_values: 0,
page: Math.floor(start / 20) + 1,
include_fields: '$skus(*),$cards(*)',
}];
// on first load (start === 0) we want to get the facets for the filters
// if (start === 0) {
// for (const facet of Object.keys(facetFields)) {
// searchArray.push({
// collection: 'cards',
// filter_by: facetFilter(facet),
// per_page: 0,
// facet_by: facet,
// max_facet_values: 500,
// page: 1,
// sort_by: '',
// include_fields: '',
// });
// }
// }
const searchRequests = { searches: searchArray };
const commonSearchParams = {
q: query,
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
// query_by: 'userId',
};
// use typesense to search for cards matching the query and return the productIds of the results
const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams);
const inventoryResults = searchResults.results[0] as any;
console.log('inventoryResults', util.inspect(inventoryResults, { depth: null }));
const pokemon = inventoryResults.hits?.map((hit: any) => hit.document) ?? [];
const totalHits = inventoryResults?.found;
console.log(`totalHits: ${totalHits}`);
---
{pokemon.map((inventory:any) => {
const sku = inventory.skus;
const card = inventory.cards;
const market = sku.marketPrice/100 || 0;
const purchase = inventory.purchasePrice/100 || 0;
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<div class="col">
<article class="inv-grid-card">
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-bs-toggle="modal" data-bs-target="#cardModal">
<div class="rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}>
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
<FirstEditionIcon edition={card.variant} />
</span>
</div>
</div>
<div class="d-flex flex-row justify-content-between my-1 align-items-center">
<input type="number" class="form-control text-center" style="max-width: 50%;" value="1" min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
<div class="" aria-label="Edit controls">
<button type="button" class="btn btn-outline-warning btn-sm"><svg class="edit-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M100.4 417.2C104.5 402.6 112.2 389.3 123 378.5L304.2 197.3L338.1 163.4C354.7 180 389.4 214.7 442.1 267.4L476 301.3L442.1 335.2L260.9 516.4C250.2 527.1 236.8 534.9 222.2 539L94.4 574.6C86.1 576.9 77.1 574.6 71 568.4C64.9 562.2 62.6 553.3 64.9 545L100.4 417.2zM156 413.5C151.6 418.2 148.4 423.9 146.7 430.1L122.6 517L209.5 492.9C215.9 491.1 221.7 487.8 226.5 483.2L155.9 413.5zM510 267.4C493.4 250.8 458.7 216.1 406 163.4L372 129.5C398.5 103 413.4 88.1 416.9 84.6C430.4 71 448.8 63.4 468 63.4C487.2 63.4 505.6 71 519.1 84.6L554.8 120.3C568.4 133.9 576 152.3 576 171.4C576 190.5 568.4 209 554.8 222.5C551.3 226 536.4 240.9 509.9 267.4z"/></svg></button>
<button type="button" class="btn btn-outline-danger btn-sm"><svg class="delete-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232.7 69.9L224 96L128 96C110.3 96 96 110.3 96 128C96 145.7 110.3 160 128 160L512 160C529.7 160 544 145.7 544 128C544 110.3 529.7 96 512 96L416 96L407.3 69.9C402.9 56.8 390.7 48 376.9 48L263.1 48C249.3 48 237.1 56.8 232.7 69.9zM512 208L128 208L149.1 531.1C150.7 556.4 171.7 576 197 576L443 576C468.3 576 489.3 556.4 490.9 531.1L512 208z"/></svg></button>
</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="p small text-secondary">{card.setName}</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="h5">{card.productName}</div>
</div>
<div class="d-flex flex-row mt-1 justify-content-between align-items-baseline">
<div class={`inv-grid-trend small ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow">{isGain ? "▲" : "▼"}</span>
<span class="h6 my-0">${market.toFixed(2)}</span>
</div>
<div class={`inv-grid-delta small ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)}</br>{isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%
</div>
</div>
</article>
</div>
);
})