--- 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"; 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()))); })(); // ── 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 = {}; 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 = {}; 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 ──────────────────────────────── // Find the oldest data point to know what ranges are meaningful 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, // meaningful if at least 2 months of data '6m': dataSpanDays >= 180, // meaningful if at least 6 months of data '1y': dataSpanDays >= 365, // meaningful if at least 9 months of data 'all': dataSpanDays >= 400, // meaningful if more than ~13 months of data }; 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: '—', monthlyVol: 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.monthlyVol * 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]; }; 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`; }; ---