diff --git a/.github/workflows/pop-visual-proof.yml b/.github/workflows/pop-visual-proof.yml new file mode 100644 index 00000000..75343a95 --- /dev/null +++ b/.github/workflows/pop-visual-proof.yml @@ -0,0 +1,101 @@ +name: POP visual proof + +on: + push: + branches: + - pop-recovery-current + workflow_dispatch: + +jobs: + screenshot: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --no-audit --no-fund + - name: Install Playwright for proof only + run: | + npm install --no-save --no-package-lock playwright@1.55.0 + npx playwright install --with-deps chromium + - run: npm run build + - name: Start index + run: | + npm run start > /tmp/index-server.log 2>&1 & + for i in {1..30}; do + if curl -fsS http://127.0.0.1:3000/basket >/dev/null; then exit 0; fi + sleep 1 + done + cat /tmp/index-server.log + exit 1 + - name: Capture standalone and iframe POP + run: | + mkdir -p /tmp/pop-proof + node <<'NODE' + const { chromium } = require('playwright'); + const fs = require('fs'); + async function loadLazyAssets(page) { + await page.evaluate(async () => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + for (let y = 0; y < document.documentElement.scrollHeight; y += Math.max(320, window.innerHeight * 0.75)) { + window.scrollTo(0, y); + await delay(80); + } + window.scrollTo(0, 0); + await delay(200); + }); + await page.waitForLoadState('networkidle'); + } + (async () => { + const browser = await chromium.launch({ + headless: true, + args: ['--host-resolver-rules=MAP pop.1d3x.com 127.0.0.1'] + }); + for (const [mode, viewport] of [['desktop',{width:1440,height:1000}], ['mobile',{width:390,height:844}]]) { + const page = await browser.newPage({ viewport }); + await page.goto('http://127.0.0.1:3000/basket', { waitUntil: 'networkidle' }); + await loadLazyAssets(page); + await page.screenshot({ path: `/tmp/pop-proof/basket-${mode}.png`, fullPage: true }); + const directMetrics = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + viewport: window.innerWidth, + height: document.documentElement.scrollHeight, + hasBasket: Boolean(document.querySelector('[data-basket-site]')), + failedImages: [...document.images].filter((img) => !img.complete || img.naturalWidth === 0).map((img) => img.getAttribute('src')), + text: document.body.innerText.slice(0, 4000) + })); + fs.writeFileSync(`/tmp/pop-proof/basket-${mode}.json`, JSON.stringify(directMetrics, null, 2)); + await page.close(); + } + + const hostPage = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + await hostPage.goto('http://pop.1d3x.com:3000/', { waitUntil: 'networkidle' }); + await loadLazyAssets(hostPage); + await hostPage.screenshot({ path: '/tmp/pop-proof/pop-host-desktop.png', fullPage: true }); + const hostMetrics = await hostPage.evaluate(() => ({ + hasBasket: Boolean(document.querySelector('[data-basket-site]')), + failedImages: [...document.images].filter((img) => !img.complete || img.naturalWidth === 0).map((img) => img.getAttribute('src')), + text: document.body.innerText.slice(0, 4000) + })); + fs.writeFileSync('/tmp/pop-proof/pop-host-desktop.json', JSON.stringify(hostMetrics, null, 2)); + await hostPage.close(); + + const iframePage = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await iframePage.setContent(''); + const frame = iframePage.frameLocator('#pop'); + await frame.locator('[data-basket-site]').waitFor({ timeout: 15000 }); + await iframePage.screenshot({ path: '/tmp/pop-proof/pop-iframe-desktop.png', fullPage: true }); + const iframeText = await frame.locator('body').innerText(); + fs.writeFileSync('/tmp/pop-proof/pop-iframe-desktop.json', JSON.stringify({ text: iframeText.slice(0, 3000) }, null, 2)); + await iframePage.close(); + await browser.close(); + })().catch((error) => { console.error(error); process.exit(1); }); + NODE + - uses: actions/upload-artifact@v4 + with: + name: pop-visual-proof + path: /tmp/pop-proof + retention-days: 3 diff --git a/public/basket/assets/api-cube.webp b/public/basket/assets/api-cube.webp new file mode 100644 index 00000000..8ae2845e Binary files /dev/null and b/public/basket/assets/api-cube.webp differ diff --git a/public/basket/assets/hero-bigmac.webp b/public/basket/assets/hero-bigmac.webp new file mode 100644 index 00000000..2b87a32c Binary files /dev/null and b/public/basket/assets/hero-bigmac.webp differ diff --git a/public/basket/assets/hero-iphone.webp b/public/basket/assets/hero-iphone.webp new file mode 100644 index 00000000..d0c7ab75 Binary files /dev/null and b/public/basket/assets/hero-iphone.webp differ diff --git a/public/basket/assets/hero-latte.webp b/public/basket/assets/hero-latte.webp new file mode 100644 index 00000000..27536b65 Binary files /dev/null and b/public/basket/assets/hero-latte.webp differ diff --git a/public/basket/assets/media-monthly-review.webp b/public/basket/assets/media-monthly-review.webp new file mode 100644 index 00000000..da5368ac Binary files /dev/null and b/public/basket/assets/media-monthly-review.webp differ diff --git a/public/basket/assets/og-basket.webp b/public/basket/assets/og-basket.webp new file mode 100644 index 00000000..249d16c7 Binary files /dev/null and b/public/basket/assets/og-basket.webp differ diff --git a/src/app/basket/page.tsx b/src/app/basket/page.tsx index d21ce5ad..f17f2d33 100644 --- a/src/app/basket/page.tsx +++ b/src/app/basket/page.tsx @@ -1,5 +1,5 @@ -import { redirect } from "next/navigation"; +import { BasketLanding } from "@/components/basket/basket-landing"; -export default function BasketCompatibilityRedirect() { - redirect("/"); +export default function BasketPage() { + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index be9fb3d0..d123ae8b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,10 @@ import { cookies } from "next/headers"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { + BasketHeroEmbed, + BasketLanding, +} from "@/components/basket/basket-landing"; import { PlatformLanding } from "@/components/platform/platform-landing"; import { detectLocaleFromCountry, @@ -16,6 +20,11 @@ export default async function HomeRedirect() { headerStore.get("host") ?? undefined; + if (isPopHost(requestHost)) { + const isIframe = headerStore.get("sec-fetch-dest") === "iframe"; + return isIframe ? : ; + } + if (isPlatformSite(requestHost ?? undefined)) { return ; } @@ -35,6 +44,23 @@ export default async function HomeRedirect() { redirect(`/${locale}`); } +function isPopHost(value?: string) { + if (!value) return false; + + const host = value + .split(",") + .map((item) => item.trim().toLowerCase()) + .find(Boolean); + + return Boolean( + host && + (host === "pop.1d3x.com" || + host.startsWith("pop.1d3x.com:") || + host.includes("1d3x-basket") || + host.includes("basket")), + ); +} + function getCountryHeader(headerStore: Headers) { return ( headerStore.get("x-vercel-ip-country") ?? diff --git a/src/components/basket/basket-landing.tsx b/src/components/basket/basket-landing.tsx new file mode 100644 index 00000000..006e72a3 --- /dev/null +++ b/src/components/basket/basket-landing.tsx @@ -0,0 +1,600 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useMemo, useState, type ReactNode } from "react"; +import { BASKET_MARKETS, BASKET_PRODUCTS } from "@/lib/basket/products"; +import { + getBasketCompare, + getBasketHistory, + getBasketLatest, + getBasketMonthlyReport, +} from "@/lib/basket/data"; +import type { BasketChartSeries, BasketLatestItem, BasketMarket } from "@/lib/basket/types"; + +export function BasketLanding({ embed = false, initialMarket = "GLOBAL" }: { embed?: boolean; initialMarket?: BasketMarket }) { + const [market, setMarket] = useState(initialMarket); + const latest = useMemo(() => getBasketLatest(market), [market]); + const history = useMemo(() => getBasketHistory(market), [market]); + const compare = useMemo(() => getBasketCompare(market), [market]); + const report = getBasketMonthlyReport(); + + return ( +
+ + {!embed ? : null} +
+
+

Consumer basket indices by 1D3X

+

+ + 1D3X Basket + + + Consumer basket indices for the real world. + +

+

+ Big Mac. Starbucks Latte. iPhone. Everyday prices turned into global signals. +

+ +
+ Independent fan project. Data sources: +
+ The Economist + McDonald's + Starbucks + Apple +
+
+
+ +
+ +
+ {latest.products.map((item) => ( + + ))} +
+
+ Baseline: United States (US) + Values show USD prices and percent change vs baseline + Last update: {new Date(latest.updatedAt).toUTCString()} + Coverage: {latest.composite.coverage.label} +
+ +
+
+
+
+

Analytics Lab

+

+ Compare consumer basket indices with currencies, commodities and SPIKE Spot Index positions. +

+
+
+ {BASKET_MARKETS.map((item) => ( + + ))} +
+
+
+ + +
+

Normalized to 100. Ukraine mode adds selected SPIKE Spot Index overlays.

+
+
+ +
+ +
    +
  • Prices are collected from credited public and monitored sources.
  • +
  • Local prices are converted to USD using daily FX rates.
  • +
  • Each index shows price level vs US baseline and movement over time.
  • +
  • Basket Composite averages available verified or monitored components.
  • +
+
+ +
+ API access visual +
+
    +
  • Latest values and history
  • +
  • Cross-index comparison
  • +
  • Source metadata and confidence
  • +
  • Embeddable widgets
  • +
+

Access available on request.

+
+ + Monthly Basket Review visual +

{report.month}

+

{report.title}

+

{report.summary}

+
+
+ + {!embed ? : null} +
+ ); +} + +export function BasketCardsEmbed({ market = "GLOBAL" }: { market?: BasketMarket }) { + const latest = getBasketLatest(market); + + return ( +
+ +
+ {latest.products.map((item) => ( + + ))} +
+
+ ); +} + +export function BasketChartEmbed({ market = "GLOBAL" }: { market?: BasketMarket }) { + return ( +
+ +
+

1D3X Basket Analytics

+ +
+
+ ); +} + +export function BasketHeroEmbed({ market = "GLOBAL" }: { market?: BasketMarket }) { + const latest = getBasketLatest(market); + + return ( +
+ +
+
+

+ 1D3X Basket +

+

Consumer basket indices for the real world.

+

Coverage: {latest.composite.coverage.label}

+
+ +
+
+ ); +} + +function BasketHeader({ market, setMarket }: { market: BasketMarket; setMarket: (market: BasketMarket) => void }) { + return ( +
+ +
+ ); +} + +function ProductStage({ compact = false }: { compact?: boolean }) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ Big Mac product visual +
+
+ +
+ +
+ ); +} + +function BasketIndexCard({ item, compact = false }: { item: BasketLatestItem; compact?: boolean }) { + const product = BASKET_PRODUCTS[item.product]; + const value = item.valueUsd === null ? "Unavailable" : item.valueUsd > 100 ? item.valueUsd.toLocaleString("en-US") : item.valueUsd.toFixed(2); + const trend = item.changeYoY === null ? "Source pending" : `${item.changeYoY > 0 ? "+" : ""}${item.changeYoY}% vs US baseline`; + + return ( +
+
+

{product.name}

+ + {item.confidence} + +
+
+ {value} + {item.valueUsd !== null ? USD : null} +
+

{trend}

+ +

Source: {item.source.label}

+ {item.note ?

{item.note}

: null} +
+ ); +} + +function BasketLineChart({ series, compact = false }: { series: BasketChartSeries[]; compact?: boolean }) { + const visible = series.slice(0, compact ? 6 : 10); + const values = visible.flatMap((item) => item.points.map((point) => point.value)); + const min = Math.min(...values) - 4; + const max = Math.max(...values) + 4; + + return ( +
+
+ {visible.map((item) => ( + + + {item.label} + + ))} +
+ + {[20, 40, 60, 80].map((y) => ( + + ))} + {visible.map((item) => ( + point.value), min, max)} + stroke={item.color} + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={item.id === "basket" ? "2.6" : "1.7"} + vectorEffect="non-scaling-stroke" + /> + ))} + +
+ ); +} + +function CorrelationPanel({ correlations }: { correlations: Array<{ id: string; label: string; correlationToBasket: number | null }> }) { + return ( + + ); +} + +function InfoPanel({ id, title, cta, children }: { id: string; title: string; cta: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+ +
+ ); +} + +function BasketFooter() { + return ( +
+
+
+

1D3X

+

+ 1D3X Basket is an independent fan and research project. Big Mac, McDonald's, Starbucks, iPhone, Apple and The Economist are trademarks or properties of their respective owners. No partnership, sponsorship or endorsement is implied. Data for informational purposes only. +

+
+
+ About + Contact +
+
+
+ ); +} + +function Sparkline({ values, color }: { values: number[]; color: string }) { + return ( + + + + ); +} + +function ArrowIcon() { + return ( + + ); +} + +function toPoints(values: number[], min: number, max: number) { + const range = Math.max(max - min, 1); + return values + .map((value, index) => { + const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 100; + const y = 88 - ((value - min) / range) * 76; + return `${x},${y}`; + }) + .join(" "); +} + +function BasketStyles() { + return ( + + ); +} diff --git a/src/lib/basket/data.ts b/src/lib/basket/data.ts new file mode 100644 index 00000000..b366c686 --- /dev/null +++ b/src/lib/basket/data.ts @@ -0,0 +1,276 @@ +import { BASKET_PRODUCTS, BASKET_SOURCES } from "@/lib/basket/products"; +import { + calculateComposite, + calculateCorrelation, + enrichObservation, + rebaseSeriesTo100, +} from "@/lib/basket/formulas"; +import type { + BasketChartSeries, + BasketLatestResponse, + BasketMarket, + BasketObservation, + BasketProductId, +} from "@/lib/basket/types"; + +const updatedAt = "2026-06-30T09:30:00.000Z"; + +const observations: BasketObservation[] = [ + { + product: "bigmac", + market: "GLOBAL", + date: "2026-06-30", + valueUsd: 6.12, + baselineUsd: 5.21, + source: BASKET_SOURCES.economistBigMac, + confidence: "verified", + status: "published", + }, + { + product: "latte", + market: "GLOBAL", + date: "2026-06-30", + valueUsd: 5.45, + baselineUsd: 4.86, + source: BASKET_SOURCES.starbucksMonitor, + confidence: "monitored", + status: "monitored", + }, + { + product: "iphone", + market: "GLOBAL", + date: "2026-06-30", + valueUsd: 1099, + baselineUsd: 1011, + source: BASKET_SOURCES.appleStore, + confidence: "seed", + status: "monitored", + }, + { + product: "bigmac", + market: "US", + date: "2026-06-30", + valueUsd: 5.21, + baselineUsd: 5.21, + source: BASKET_SOURCES.economistBigMac, + confidence: "verified", + status: "published", + }, + { + product: "latte", + market: "US", + date: "2026-06-30", + valueUsd: 4.86, + baselineUsd: 4.86, + source: BASKET_SOURCES.starbucksMonitor, + confidence: "monitored", + status: "monitored", + }, + { + product: "iphone", + market: "US", + date: "2026-06-30", + valueUsd: 999, + baselineUsd: 999, + source: BASKET_SOURCES.appleStore, + confidence: "verified", + status: "published", + }, + { + product: "bigmac", + market: "UA", + date: "2026-06-30", + valueUsd: 4.34, + baselineUsd: 5.21, + source: BASKET_SOURCES.economistBigMac, + confidence: "verified", + status: "published", + }, + { + product: "latte", + market: "UA", + date: "2026-06-30", + valueUsd: null, + baselineUsd: 4.86, + source: BASKET_SOURCES.starbucksMonitor, + confidence: "unavailable", + status: "unavailable", + note: "Ukraine Starbucks Latte source is not verified for MVP.", + }, + { + product: "iphone", + market: "UA", + date: "2026-06-30", + valueUsd: 1180, + baselineUsd: 999, + source: BASKET_SOURCES.appleStore, + confidence: "seed", + status: "monitored", + }, +]; + +const productSparklines: Record = { + bigmac: [97, 98, 96, 99, 101, 103, 102, 106, 108, 111, 113, 117], + latte: [94, 96, 99, 98, 101, 104, 103, 106, 108, 110, 111, 112], + iphone: [100, 101, 103, 105, 104, 106, 108, 109, 110, 111, 112, 113], +}; + +const yoy: Record = { + bigmac: 17.4, + latte: 12.1, + iphone: 8.7, +}; + +const baseHistory = [ + { date: "2025-07-01", basket: 100, bigmac: 100, latte: 100, iphone: 100, usd: 100, brent: 100, gold: 100 }, + { date: "2025-08-01", basket: 104, bigmac: 103, latte: 101, iphone: 107, usd: 101, brent: 95, gold: 102 }, + { date: "2025-09-01", basket: 108, bigmac: 105, latte: 103, iphone: 116, usd: 102, brent: 91, gold: 105 }, + { date: "2025-10-01", basket: 106, bigmac: 102, latte: 105, iphone: 111, usd: 103, brent: 86, gold: 107 }, + { date: "2025-11-01", basket: 109, bigmac: 108, latte: 106, iphone: 114, usd: 101, brent: 82, gold: 110 }, + { date: "2025-12-01", basket: 111, bigmac: 110, latte: 108, iphone: 116, usd: 100, brent: 88, gold: 113 }, + { date: "2026-01-01", basket: 114, bigmac: 112, latte: 110, iphone: 121, usd: 102, brent: 92, gold: 116 }, + { date: "2026-02-01", basket: 116, bigmac: 115, latte: 111, iphone: 123, usd: 103, brent: 96, gold: 119 }, + { date: "2026-03-01", basket: 119, bigmac: 118, latte: 114, iphone: 126, usd: 104, brent: 93, gold: 123 }, + { date: "2026-04-01", basket: 121, bigmac: 120, latte: 116, iphone: 129, usd: 105, brent: 97, gold: 127 }, + { date: "2026-05-01", basket: 124, bigmac: 123, latte: 118, iphone: 132, usd: 104, brent: 99, gold: 130 }, + { date: "2026-06-01", basket: 127, bigmac: 126, latte: 121, iphone: 134, usd: 106, brent: 101, gold: 133 }, +] as const; + +const spikeUaHistory = [ + { date: "2025-07-01", corn: 100, wheat: 100, sunflower: 100 }, + { date: "2025-08-01", corn: 97, wheat: 99, sunflower: 104 }, + { date: "2025-09-01", corn: 92, wheat: 95, sunflower: 108 }, + { date: "2025-10-01", corn: 89, wheat: 93, sunflower: 106 }, + { date: "2025-11-01", corn: 91, wheat: 94, sunflower: 109 }, + { date: "2025-12-01", corn: 96, wheat: 98, sunflower: 112 }, + { date: "2026-01-01", corn: 103, wheat: 104, sunflower: 115 }, + { date: "2026-02-01", corn: 108, wheat: 107, sunflower: 117 }, + { date: "2026-03-01", corn: 111, wheat: 110, sunflower: 121 }, + { date: "2026-04-01", corn: 114, wheat: 113, sunflower: 124 }, + { date: "2026-05-01", corn: 118, wheat: 116, sunflower: 127 }, + { date: "2026-06-01", corn: 121, wheat: 119, sunflower: 131 }, +] as const; + +export function normalizeBasketMarket(value: string | undefined | null): BasketMarket { + const upper = value?.toUpperCase(); + return upper === "US" || upper === "UA" || upper === "GLOBAL" ? upper : "GLOBAL"; +} + +export function getBasketLatest(market: BasketMarket = "GLOBAL"): BasketLatestResponse { + const marketObservations = observations.filter((item) => item.market === market); + const products = marketObservations.map((observation) => + enrichObservation(observation, { + changeYoY: observation.status === "unavailable" ? null : yoy[observation.product], + sparkline: productSparklines[observation.product], + }), + ); + const composite = calculateComposite(products); + + return { + market, + updatedAt, + products, + composite, + }; +} + +export function getBasketSources() { + return Object.values(BASKET_SOURCES); +} + +export function getBasketHistory(market: BasketMarket = "GLOBAL"): BasketChartSeries[] { + const productAvailability = getBasketLatest(market).products; + const unavailable = new Set( + productAvailability.filter((item) => item.status === "unavailable").map((item) => item.product), + ); + const productSeries = [ + makeSeries("basket", "Basket Composite", "#ffc42e", "1D3X Basket composite", "basket"), + makeSeries("bigmac", BASKET_PRODUCTS.bigmac.name, BASKET_PRODUCTS.bigmac.accent, BASKET_SOURCES.economistBigMac.label, "bigmac"), + !unavailable.has("latte") + ? makeSeries("latte", BASKET_PRODUCTS.latte.name, BASKET_PRODUCTS.latte.accent, BASKET_SOURCES.starbucksMonitor.label, "latte") + : null, + makeSeries("iphone", BASKET_PRODUCTS.iphone.name, BASKET_PRODUCTS.iphone.accent, BASKET_SOURCES.appleStore.label, "iphone"), + makeSeries("usd", "USD Broad Index", "#4aa3ff", BASKET_SOURCES.fredUsd.label, "usd"), + makeSeries("brent", "Brent Oil", "#ff7043", BASKET_SOURCES.commodityProxy.label, "brent"), + makeSeries("gold", "Gold proxy", "#ffd166", BASKET_SOURCES.commodityProxy.label, "gold"), + ].filter((item): item is BasketChartSeries => Boolean(item)); + + if (market !== "UA") return productSeries; + + return [ + ...productSeries, + makeSpikeSeries("spike-corn", "SPIKE Corn CPT Odesa", "#20d47b", "corn"), + makeSpikeSeries("spike-wheat", "SPIKE Milling Wheat CPT Odesa", "#25d6d0", "wheat"), + makeSpikeSeries("spike-sunflower", "SPIKE Sunflower Seed", "#ffb000", "sunflower"), + ]; +} + +export function getBasketCompare(market: BasketMarket = "GLOBAL") { + const series = getBasketHistory(market); + const basket = series.find((item) => item.id === "basket"); + const correlations = series.map((item) => ({ + id: item.id, + label: item.label, + correlationToBasket: + basket && item.id !== "basket" + ? calculateCorrelation( + basket.points.map((point) => point.value), + item.points.map((point) => point.value), + ) + : item.id === "basket" + ? 1 + : null, + })); + + return { + market, + mode: "rebasedTo100", + series, + correlations, + generatedAt: new Date().toISOString(), + }; +} + +export function getBasketMonthlyReport() { + return { + id: "basket-review-2026-06", + title: "Global Consumer Basket Review", + month: "June 2026", + status: "seed", + summary: + "Big Mac and iPhone signals remain above the US baseline while Starbucks Latte coverage is monitored and source-dependent by market.", + sourceGroup: "basket-global", + cadence: "monthly", + }; +} + +function makeSeries( + id: string, + label: string, + color: string, + source: string, + key: keyof (typeof baseHistory)[number], +): BasketChartSeries { + return { + id, + label, + color, + source, + points: rebaseSeriesTo100(baseHistory.map((point) => ({ date: point.date, value: Number(point[key]) }))), + }; +} + +function makeSpikeSeries( + id: string, + label: string, + keyColor: string, + key: keyof (typeof spikeUaHistory)[number], +): BasketChartSeries { + return { + id, + label, + color: keyColor, + source: BASKET_SOURCES.spikePublic.label, + points: rebaseSeriesTo100(spikeUaHistory.map((point) => ({ date: point.date, value: Number(point[key]) }))), + }; +} diff --git a/src/lib/basket/formulas.ts b/src/lib/basket/formulas.ts new file mode 100644 index 00000000..d1e46eaf --- /dev/null +++ b/src/lib/basket/formulas.ts @@ -0,0 +1,98 @@ +import type { BasketCoverage, BasketLatestItem, BasketObservation, BasketSeriesPoint } from "@/lib/basket/types"; + +export function toUsdPrice(localPrice: number, usdFxRate: number) { + if (usdFxRate <= 0) throw new Error("FX rate must be positive"); + return roundMoney(localPrice / usdFxRate); +} + +export function calculateIndexVsBaseline(valueUsd: number | null, baselineUsd: number) { + if (valueUsd === null || baselineUsd <= 0) return null; + return roundPercent(((valueUsd - baselineUsd) / baselineUsd) * 100); +} + +export function calculateComposite(items: BasketLatestItem[]) { + const values = items + .map((item) => item.indexVsBaseline) + .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + + const coverage = getCoverage(values.length, items.length); + + return { + value: values.length > 0 ? roundPercent(values.reduce((sum, value) => sum + value, 0) / values.length) : null, + coverage, + }; +} + +export function getCoverage(available: number, total: number): BasketCoverage { + return { + available, + total, + label: `${available} / ${total} components`, + }; +} + +export function enrichObservation( + observation: BasketObservation, + options: { changeYoY: number | null; sparkline: number[] }, +): BasketLatestItem { + return { + ...observation, + changeYoY: options.changeYoY, + indexVsBaseline: calculateIndexVsBaseline(observation.valueUsd, observation.baselineUsd), + sparkline: options.sparkline, + }; +} + +export function rebaseSeriesTo100(points: Array<{ date: string; value: number }>) { + const first = points.find((point) => Number.isFinite(point.value) && point.value > 0); + + if (!first) return []; + + return points.map((point) => ({ + date: point.date, + value: roundPercent((point.value / first.value) * 100), + })); +} + +export function calculateCorrelation(a: number[], b: number[]) { + const length = Math.min(a.length, b.length); + if (length < 2) return null; + + const left = a.slice(-length); + const right = b.slice(-length); + const avgLeft = average(left); + const avgRight = average(right); + const numerator = left.reduce((sum, value, index) => sum + (value - avgLeft) * (right[index] - avgRight), 0); + const leftVariance = left.reduce((sum, value) => sum + (value - avgLeft) ** 2, 0); + const rightVariance = right.reduce((sum, value) => sum + (value - avgRight) ** 2, 0); + const denominator = Math.sqrt(leftVariance * rightVariance); + + return denominator === 0 ? null : Number((numerator / denominator).toFixed(2)); +} + +export function alignSeriesByDate(series: BasketSeriesPoint[]) { + const grouped = new Map>(); + + for (const point of series) { + grouped.set(point.date, { + ...(grouped.get(point.date) ?? {}), + [point.seriesId]: point.value, + }); + } + + return [...grouped.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, values]) => ({ date, values })); +} + +function average(values: number[]) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function roundMoney(value: number) { + return Number(value.toFixed(2)); +} + +function roundPercent(value: number) { + return Number(value.toFixed(2)); +} diff --git a/src/lib/basket/products.ts b/src/lib/basket/products.ts new file mode 100644 index 00000000..6f132d56 --- /dev/null +++ b/src/lib/basket/products.ts @@ -0,0 +1,118 @@ +import type { BasketMarket, BasketProduct, BasketProductId, BasketSource } from "@/lib/basket/types"; + +export const BASKET_PRODUCTS: Record = { + bigmac: { + id: "bigmac", + name: "Big Mac Index", + shortName: "Big Mac", + accent: "#ffc42e", + unit: "item", + }, + latte: { + id: "latte", + name: "Starbucks Latte Index", + shortName: "Latte", + accent: "#70f2bd", + unit: "item", + }, + iphone: { + id: "iphone", + name: "iPhone Index", + shortName: "iPhone", + accent: "#b96cff", + unit: "item", + }, +}; + +export const BASKET_MARKETS: Array<{ id: BasketMarket; label: string }> = [ + { id: "GLOBAL", label: "Global" }, + { id: "US", label: "United States" }, + { id: "UA", label: "Ukraine" }, +]; + +export const BASKET_SOURCES: Record = { + economistBigMac: { + id: "economist-bigmac", + kind: "price_dataset", + label: "The Economist Big Mac Index dataset", + url: "https://github.com/TheEconomist/big-mac-data", + }, + starbucksMonitor: { + id: "starbucks-menu-monitor", + kind: "brand_menu", + label: "Monitored Starbucks menu prices / public menu datasets", + }, + gcpdexStarbucks: { + id: "gcpdex-starbucks", + kind: "price_dataset", + label: "GCPDex Starbucks Latte Index reference dataset", + url: "https://www.gcpdex.com/", + }, + appleStore: { + id: "apple-store-retail", + kind: "retail_price", + label: "Apple Store / authorized retailer price monitoring", + }, + picodiIphone: { + id: "picodi-iphone-index", + kind: "price_dataset", + label: "Picodi iPhone Index annual affordability report", + url: "https://www.picodi.com/", + }, + tenscopeIphone: { + id: "tenscope-iphone-affordability", + kind: "price_dataset", + label: "Tenscope iPhone Affordability Index", + url: "https://tenscope.com/", + }, + applePriceCompare: { + id: "apple-price-compare", + kind: "retail_price", + label: "ApplePriceCompare Apple Store country price tables", + url: "https://applepricecompare.com/", + }, + fredUsd: { + id: "fred-usd-broad", + kind: "external_market_series", + label: "FRED broad USD proxy", + url: "https://fred.stlouisfed.org/series/DTWEXBGS", + }, + fredBrent: { + id: "fred-brent", + kind: "external_market_series", + label: "FRED Brent crude oil spot price", + url: "https://fred.stlouisfed.org/series/DCOILBRENTEU", + }, + fredWti: { + id: "fred-wti", + kind: "external_market_series", + label: "FRED WTI crude oil spot price", + url: "https://fred.stlouisfed.org/series/DCOILWTICO", + }, + commodityProxy: { + id: "commodity-proxy", + kind: "external_market_series", + label: "Public commodity market proxy", + }, + spikePublic: { + id: "spike-public-history", + kind: "external_market_series", + label: "SPIKE Spot Index public history", + }, + economistMedia: { + id: "economist-burgernomics", + kind: "news", + label: "The Economist Burgernomics coverage", + url: "https://www.economist.com/big-mac-index", + }, + consumerIndexMedia: { + id: "consumer-index-media", + kind: "rss", + label: "Consumer index media monitoring: Visual Capitalist, Investopedia, SwitchOnBusiness, Yahoo Finance, CNBC, Reuters, Bloomberg", + }, + communityDataSignals: { + id: "community-data-signals", + kind: "news", + label: "Community data signals for non-official consumer index leads", + }, +}; diff --git a/src/lib/basket/types.ts b/src/lib/basket/types.ts new file mode 100644 index 00000000..a13a6d90 --- /dev/null +++ b/src/lib/basket/types.ts @@ -0,0 +1,80 @@ +export type BasketProductId = "bigmac" | "latte" | "iphone"; +export type BasketMarket = "GLOBAL" | "US" | "UA"; +export type BasketConfidence = "verified" | "monitored" | "seed" | "unavailable"; +export type BasketObservationStatus = "published" | "monitored" | "unavailable"; + +export type BasketSourceKind = + | "price_dataset" + | "brand_menu" + | "retail_price" + | "fx" + | "external_market_series" + | "news" + | "rss" + | "telegram" + | "manual_material"; + +export type BasketSource = { + id: string; + label: string; + kind: BasketSourceKind; + url?: string; +}; + +export type BasketProduct = { + id: BasketProductId; + name: string; + shortName: string; + accent: string; + unit: "item"; +}; + +export type BasketObservation = { + product: BasketProductId; + market: BasketMarket; + date: string; + valueUsd: number | null; + baselineUsd: number; + localPrice?: number | null; + currencyCode?: string | null; + source: BasketSource; + confidence: BasketConfidence; + status: BasketObservationStatus; + note?: string; +}; + +export type BasketLatestItem = BasketObservation & { + indexVsBaseline: number | null; + changeYoY: number | null; + sparkline: number[]; +}; + +export type BasketSeriesPoint = { + date: string; + seriesId: string; + value: number; +}; + +export type BasketChartSeries = { + id: string; + label: string; + color: string; + points: Array<{ date: string; value: number }>; + source: string; +}; + +export type BasketCoverage = { + available: number; + total: number; + label: string; +}; + +export type BasketLatestResponse = { + market: BasketMarket; + updatedAt: string; + products: BasketLatestItem[]; + composite: { + value: number | null; + coverage: BasketCoverage; + }; +};