--- import ebay from "/vendors/ebay.svg?raw"; import SetIcon from '../../components/SetIcon.astro'; import EnergyIcon from '../../components/EnergyIcon.astro'; import RarityIcon from '../../components/RarityIcon.astro'; import { db } from '../../db/index'; import { priceHistory, skus } from '../../db/schema'; import { eq, inArray } from 'drizzle-orm'; import FirstEditionIcon from "../../components/FirstEditionIcon.astro"; import { Tooltip } from "bootstrap"; // auth check for inventory management features //const { canAddInventory } = Astro.locals; const canAddInventory = false; export const partial = true; export const prerender = false; const searchParams = Astro.url.searchParams; const cardId = Number(searchParams.get('cardId')) || 0; const card = await db.query.cards.findFirst({ where: { cardId: Number(cardId) }, with: { prices: true, set: true, } }); function timeAgo(date: Date | null) { if (!date) return "Not applicable"; const seconds = Math.floor((Date.now() - date.getTime()) / 1000); const intervals: Record = { year: 31536000, month: 2592000, day: 86400, 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"; } const calculatedAt = (() => { if (!card?.prices?.length) return null; const dates = card.prices .map(p => p.calculatedAt) .filter(d => d) .map(d => new Date(d!)); if (!dates.length) return null; return new Date(Math.max(...dates.map(d => d.getTime()))); })(); // ── Spread-based volatility (high - low) / low ──────────────────────────── // Log-return volatility was unreliable because marketPrice is a smoothed daily // value, not transaction-driven. The 30-day high/low spread is a more honest // proxy for price movement over the period. const volatilityByCondition: Record = {}; for (const price of card?.prices ?? []) { const condition = price.condition; const low = Number(price.lowestPrice); const high = Number(price.highestPrice); const market = Number(price.marketPrice); if (!low || !high || !market || market <= 0) { volatilityByCondition[condition] = { label: '—', spread: 0 }; continue; } const spread = (high - low) / market; const label = spread >= 0.50 ? 'High' : spread >= 0.25 ? 'Medium' : 'Low'; volatilityByCondition[condition] = { label, spread: Math.round(spread * 100) / 100 }; } // ── Price history for chart ─────────────────────────────────────────────── const cardSkus = card?.prices?.length ? await db.select().from(skus).where(eq(skus.productId, card.productId)) : []; 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) : []; 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); const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; const conditionAttributes = (price: any) => { const condition: string = price?.condition || "Near Mint"; const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 }; const volatilityClass = (() => { switch (vol.label) { case "High": return "alert-danger"; case "Medium": return "alert-warning"; case "Low": return "alert-success"; default: return "alert-dark"; } })(); const volatilityDisplay = vol.label === '—' ? '—' : `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`; return { "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]; }; // ── Build a market price lookup keyed by condition for use in JS ────────── const marketPriceByCondition: Record = {}; for (const price of card?.prices ?? []) { if (price.condition && price.marketPrice != null) { marketPriceByCondition[price.condition] = Number(price.marketPrice); } } // ── Derive distinct variants available for this card ───────────────────── const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort(); const ebaySearchUrl = (card: any) => { return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`; }; const altSearchUrl = (card: any) => { return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`; }; ---