diff --git a/src/components/CardGrid.astro b/src/components/CardGrid.astro index ac8f0f0..759e9b5 100644 --- a/src/components/CardGrid.astro +++ b/src/components/CardGrid.astro @@ -1,5 +1,6 @@ --- import BackToTop from "./BackToTop.astro" +import LeftSidebarDesktop from "./LeftSidebarDesktop.astro" ---
@@ -13,6 +14,7 @@ import BackToTop from "./BackToTop.astro"
+
@@ -388,5 +390,11 @@ import BackToTop from "./BackToTop.astro" currentCardId = null; updateNavButtons(null); }); + + // ── AdSense re-init on infinite scroll ─────────────────────────────────── + document.addEventListener('htmx:afterSwap', () => { + (window.adsbygoogle = window.adsbygoogle || []).push({}); + }); + })(); \ No newline at end of file diff --git a/src/components/LeftSidebarDesktop.astro b/src/components/LeftSidebarDesktop.astro new file mode 100644 index 0000000..9f45074 --- /dev/null +++ b/src/components/LeftSidebarDesktop.astro @@ -0,0 +1,7 @@ +
+ +
\ No newline at end of file diff --git a/src/layouts/Main.astro b/src/layouts/Main.astro index dfe03d6..70de6ff 100644 --- a/src/layouts/Main.astro +++ b/src/layouts/Main.astro @@ -42,5 +42,7 @@ const { title } = Astro.props; + + diff --git a/src/pages/index.astro b/src/pages/index.astro index f4c3034..51e694f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,7 +4,7 @@ import Layout from '../layouts/Main.astro'; import NavItems from '../components/NavItems.astro'; import NavBar from '../components/NavBar.astro'; import Footer from '../components/Footer.astro'; -import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components' +import { Show, SignInButton, SignUpButton, SignOutButton, GoogleOneTap, UserAvatar, UserButton, UserProfile } from '@clerk/astro/components' --- @@ -16,31 +16,41 @@ import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/co

(working title)

-

Welcome!

+

The Pokémon card tracker you actually want.

- You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well. + Browse real market prices and condition data across 70,000+ cards! No more + juggling multiple tabs or guessing what your cards are worth.

- After the closed beta is complete, the app will move into a more open beta. Feel free to play "Who's that Pokémon?" with the random Pokémon generator here. Refresh the page to see a new Pokémon! + We're now open to everyone. Create a free account to get started — + collection and inventory management tools are coming soon as part of a + premium plan.

- Take me to the cards + Take me to the cards!
-
+
- - - +
- + + +

Already have an account?

+ +
+

Free to join!

+
+
+
- + +
diff --git a/src/pages/partials/card-modal.astro b/src/pages/partials/card-modal.astro index 94c2114..96c8cac 100644 --- a/src/pages/partials/card-modal.astro +++ b/src/pages/partials/card-modal.astro @@ -51,9 +51,36 @@ const calculatedAt = (() => { return new Date(Math.max(...dates.map(d => d.getTime()))); })(); -// ── Fetch price history + compute volatility ────────────────────────────── +// ── 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.cardId, cardId)) + ? await db.select().from(skus).where(eq(skus.productId, card.productId)) : []; const skuIds = cardSkus.map(s => s.skuId); @@ -72,50 +99,6 @@ const historyRows = skuIds.length .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++) { - const ratio = prices[i] / prices[i - 1]; - if (!isFinite(ratio) || ratio <= 0) continue; // skip bad ratios - returns.push(Math.log(ratio)); - } - - if (returns.length < 2) return { label: '—', monthlyVol: 0 }; // ← key fix - - 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); - - if (!isFinite(monthlyVol)) return { label: '—', monthlyVol: 0 }; // safety net - - 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 @@ -124,29 +107,11 @@ const priceHistoryForChart = historyRows.map(row => ({ 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 condition: string = price?.condition || "Near Mint"; - const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 }; + const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 }; const volatilityClass = (() => { switch (vol.label) { @@ -159,7 +124,7 @@ const conditionAttributes = (price: any) => { const volatilityDisplay = vol.label === '—' ? '—' - : `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`; + : `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`; return { "Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" }, @@ -199,9 +164,6 @@ const altSearchUrl = (card: any) => {
- -
{

${price.marketPrice}

-
Lowest Price
+
Low Price (30 day)

${price.lowestPrice}

-
Highest Price
+
High Price (30 day)

${price.highestPrice}

@@ -298,14 +260,14 @@ const altSearchUrl = (card: any) => { data-bs-trigger="hover focus click" data-bs-html="true" data-bs-title={` -
Monthly Volatility
+
30-Day Price Spread

- What this measures: how much the market price tends to move day-to-day, - scaled up to a monthly expectation. + What this measures: how wide the gap between the 30-day low and high is, + relative to the market price.

- A card with 30% volatility typically swings ±30% over a month. + A card with 50%+ spread has seen significant price swings over the past month.

`} @@ -366,11 +328,11 @@ const altSearchUrl = (card: any) => {
- {showRanges['1m'] && } - {showRanges['3m'] && } - {showRanges['6m'] && } - {showRanges['1y'] && } - {showRanges['all'] && } + + + + +
@@ -391,4 +353,4 @@ const altSearchUrl = (card: any) => {
- + \ No newline at end of file diff --git a/src/pages/partials/cards.astro b/src/pages/partials/cards.astro index 5731fd2..b7c1018 100644 --- a/src/pages/partials/cards.astro +++ b/src/pages/partials/cards.astro @@ -322,34 +322,52 @@ const facets = searchResults.results.slice(1).map((result: any) => { )} -{pokemon.map((card:any) => ( -
-
-
+/-
+{pokemon.map((card: any, i: number) => ( + <> + {i > 0 && i % 10 === 0 && ( +
+
+
Sponsored
+
-
-
{card.productName} -
-
-
-
-
- {conditionOrder.map((condition) => ( -
- { conditionShort(condition) } -
{formatPrice(condition, card.skus)} -
- ))} -
-
{card.productName}
-
-
{card.setName}
-
{card.number}
- -
-
{card.variant}
{card.productId} -
+
+ )} +
+
+
+/-
+
+
+
+ {card.productName} + +
+
+
+
+
+ {conditionOrder.map((condition) => ( +
+ {conditionShort(condition)} +
{formatPrice(condition, card.skus)} +
+ ))} +
+
{card.productName}
+
+
{card.setName}
+
{card.number}
+ +
+
{card.variant}
+ {card.productId} +
+ ))} {start + 20 < totalHits &&