diff --git a/.env.example b/.env.example index 0176bb2..7e4c0d4 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,8 @@ X_API_KEY= X_API_SECRET= X_ACCESS_TOKEN= X_ACCESS_TOKEN_SECRET= + +# Privacy-respecting pageview analytics (GoatCounter, https://www.goatcounter.com). +# Your site code, e.g. "gridtilt" for gridtilt.goatcounter.com. Build-time var: +# set it in deployment secrets before the build. Unset = analytics fully off. +VITE_GOATCOUNTER_CODE= diff --git a/CLAUDE.md b/CLAUDE.md index 6fdb0e0..bd776e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,13 +40,13 @@ The G-chord list is maintained twice in App.tsx (display ~67-83, handler ~250-25 - React 18.3.1, TypeScript 5.6.3 (exact pin), Vite ^7.3.0, wouter ^3.3.5. NOT Next.js. - Express ^5.0.1, one process: Vite middleware in dev, static from dist/public in prod. Port 5000. - @tanstack/react-query ^5.60.5 is the only client data layer. No redux/zustand/app context. -- Tailwind ^3.4.17 via PostCSS + shadcn/ui (new-york). The 15 files in client/src/components/ui +- Tailwind ^3.4.17 via PostCSS + shadcn/ui (new-york). The 12 files in client/src/components/ui are a house-modified fork (Replit elevate classes, custom tokens); do not regenerate from upstream. - Charts: recharts ^2.15.2 (9 pages), react-leaflet ^4.2.1 (Power Map, Compute Frontier), d3 ^7.9.0 (Supply Chain force graph only). - Server libs: yahoo-finance2 ^3.13.1, rss-parser, satori + @resvg/resvg-js (OG cards), zod, helmet, express-rate-limit. -- Tests: Node's built-in runner. `npm test` = 10 suites in server/__tests__. Zero client tests. +- Tests: Node's built-in runner. `npm test` = server/__tests__ + client/src/lib/__tests__ (217 tests). No component/DOM tests. - Scripts: `dev` (tsx), `build` (script/build.ts: vite client + esbuild server to dist/index.cjs), `start`, `check` (tsc), `test`, `backtest:indices`. - Persistence: JSON in server/data/ plus content/blog/articles.json. There is NO database, @@ -143,11 +143,12 @@ server/data custody (the fragility map): Documented, not to fix casually or silently: -- FAKE DATA STILL LIVE: /api/stack serves a Math.random CCJ/CEG correlation scatter, regenerated - each request (routes.ts ~426-466, ~1959-1985). The only fabricated data on the server; - contradicts the honest-data doctrine. Unmerged PR #2 removes it. -- Unmerged security hardening: PR #1 branch fix/m0-m1-truth-security (now conflicts with main) - holds five verified low/medium closes; cherry-pick commit da97234 rather than rebasing. +- Fake-data debt CLOSED on feat/live-gpu-prices (2026-07-04): the CCJ/CEG scatter now computes + from real SRUUF/CCJ/CEG weekly closes (server/uranium-correlation.ts, tested); fetch failure + serves empty + null r, never invented dots. PR #2's removal approach is obsolete. +- Security closes from PR #1 (da97234) APPLIED on feat/live-gpu-prices (2026-07-04): SEC-1..5 + + auth-boundary tests. PR #1 itself is obsolete once that branch merges. Note: cron-job.org needs + a second job POSTing /api/admin/scan-news-now (x-admin-key) to restore automated news scans. - PR #2 (feat/real-metrics) proposes retiring the sentiment indices for a sourced scoreboard; main kept the indices (served at /api/kpis, out of the social rotation). Owner decision pending. - No CI (no .github/ at all). Tests and tsc run only when someone remembers. @@ -158,17 +159,12 @@ Documented, not to fix casually or silently: darwin; the off-darwin conditional is stranded in unmerged PR #1. - routes.ts is a 3,913-line monolith (70 routes + OAuth client + OG renderer + 3 scanners + static data tables). -- README drift: phantom Drizzle/Postgres/shared/storage.ts block, stale counts (says 60+ tickers - / 8 layers; truth 100 / 13; supply chain is 24 nodes / 52 links), missing the four 2026-06-29 - modules. replit.md is a deep fossil from March and Replit's agent reads it. HANDOFF.md and - HOMEPAGE_HANDOFF.md are historical records, not current state. -- Dead weight: date-fns and @tailwindcss/vite deps (imported nowhere), client/src/lib/dates.ts, - ui/dialog.tsx, ui/toggle.tsx, EmailCapture's unused marketing branch (~200 lines), .replit's - postgresql-16 and python-3.11 modules, package name "rest-express". -- ETN is in the stack tickers but missing from STATIC_MARKET_DATA: it vanishes instead of going - stale when Yahoo fails. -- supply-chain-stages.json bottleneck copy hardcodes stale prices (copper $10,200/ton; uranium - $80+ vs $92 in market-constants.json). +- README + replit.md rewritten from code truth (2026-07-04, feat/live-gpu-prices). HANDOFF.md and + HOMEPAGE_HANDOFF.md remain historical records, not current state. +- Dead weight (mostly cleared 2026-07-04): date-fns, @tailwindcss/vite, @radix-ui/react-toggle, + dates.ts, ui/dialog.tsx, ui/toggle.tsx removed; package renamed "gridtilt". Still open: + EmailCapture's unused marketing branch (~200 lines), .replit's postgresql-16 and python-3.11 + modules. ## 7. Deploy (the gotcha that keeps biting) diff --git a/README.md b/README.md index 04556ce..efa4692 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Equities, infrastructure, and power data for the AI power economy. -GridTilt is a research dashboard for tracking the financial and physical buildout of AI infrastructure — compute, data centers, generation, transmission, and the public companies positioned around them. It combines live equity quotes, hand-curated infrastructure datasets, and grid-relevant news so you can see where capital and electrons are actually flowing. +GridTilt is a research dashboard tracking the financial and physical buildout of AI infrastructure: compute, data centers, generation, transmission, and the public companies positioned around them. It combines live equity quotes, live GPU rental prices, hand-curated infrastructure datasets, and grid-relevant news. Live: **[gridtilt.com](https://gridtilt.com)** @@ -10,111 +10,114 @@ Live: **[gridtilt.com](https://gridtilt.com)** --- -## What it does +## Tools -| Module | What's inside | -|---|---| -| **Tilt Overview** | Top movers, sector pulse, catalyst calendar, US electricity demand chart (2010 → 2030 projection), thesis-health KPIs. | -| **The Stack** | 60+ tickers across 8 supply-chain layers (compute, nuclear, uranium, power hardware, utilities, construction, hyperscalers, REITs). | -| **Power Map** | US data center locations with power capacity and the utility / RTO they sit on. | -| **Compute Frontier** | 235 named AI superclusters across 77 operators, tracked by GPUs, chip type, rated and planned power, grid region, and energy source, tied to the nuclear-for-AI deals that feed them. Sourced figures are separated from labeled estimates. | -| **Supply Chain** | D3 force graph of 21 nodes and 44 real supply relationships from raw materials to end-use compute. | -| **Catalyst Tracker** | Earnings + thesis catalyst calendar across 80+ tickers. | -| **Portfolio Overlay** | Score any portfolio 0–100 on AI-power exposure across 5 dimensions. | -| **Thesis Calculator** | Sliders for demand growth, nuclear capacity, and grid stress to model scenarios. | -| **News Ticker** | 7-day rolling feed from 8 industry RSS sources (Utility Dive, DCD, World Nuclear News, POWER Magazine, Power Engineering, Latitude Media, DOE, EIA). | +Eight tools in the sidebar. Retired routes (`/supply-chain`, `/power-deals`, `/queue`, `/gpu-economics`, `/trade`, `/portfolio`, `/brief`) redirect into tabs of these. ---- - -## Data sources - -| Data | Source | Freshness | +| Tool | Route | What's inside | |---|---|---| -| Equity quotes, P/E, fundamentals | Yahoo Finance via `yahoo-finance2` (unofficial, indicative) | Live, with labeled static fallback when throttled | -| US electricity demand chart | Static annual snapshot (2010–2025) hand-transcribed from [EIA Electric Power Monthly](https://www.eia.gov/electricity/monthly/); 2026–2030 lines are GridTilt projections, not forecasts | Static, embedded in the client | -| Physical electricity output | FRED [`IPG2211A2N`](https://fred.stlouisfed.org/series/IPG2211A2N) served live at `/api/physical/electricity-output`; EIA US48 hourly demand at `/api/physical/load-hourly` once `EIA_API_KEY` is set | Live (FRED daily cache; EIA 30-min cache) | -| Data center locations | Public announcements (Microsoft, Google, Amazon, Meta, Apple, xAI, OpenAI, Oracle), curated through a reviewed RSS ingestion pipeline | Curated, refreshed as announcements land | -| AI superclusters (Compute Frontier) | Public announcements (company press releases, Reuters, Tom's Hardware, Data Center Dynamics, SemiAnalysis, utility filings), cross-referenced to the Power Map registry. Each cluster carries its own source links. A per-cluster `estimated[]` list flags every value that is a GridTilt estimate or an announced target not yet realized; GPU counts appear only where an operator has disclosed one, else they read as not disclosed. Curated in `server/data/clusters.json` | Curated, refreshed as announcements land | -| Industry news | Live RSS from 8 publications | Live, refreshed hourly | -| AI Demand, Grid Stress, NPI | Composite indices computed from constituent **equity moves** (NPI also uses uranium spot and a hand-derived policy score). They are market-based gauges, **not** physical grid measurements. See [Index methodology](#index-methodology). | Live, with labeled static fallback | +| **Tilt Overview** | `/overview` | Measured headline gauges (tracked AI data center power in GW, fleet-average GPU rental cost, tightest reserve margin among AI-load RTOs), tracked buildout over time, top movers, sector pulse, next catalysts, and a US electricity demand chart (static annual series from EIA Electric Power Monthly with labeled projections). | +| **The Stack** | `/stack` | 100 tickers across 13 supply-chain layers with live quotes and sparklines. Card, table, heatmap, and flow views; the flow view is a d3 force graph of 24 supply-chain nodes and 52 real supply relationships. Also carries the uranium correlation card (see below). | +| **Power** | `/power-map` | US data center facility map (Leaflet), plus tabs for AI power deals and the grid interconnection queue. | +| **Compute Frontier** | `/compute-frontier` | 235 named AI superclusters across 77 operators: GPUs, chip type, rated and planned power, grid region, energy source. Each cluster carries its own source links, and every estimated value sits in a per-cluster `estimated[]` list. GPU counts appear only where an operator disclosed one. | +| **GPU Prices** | `/neocloud-intel` | GPU rental price index built from live marketplace observations, with per-model price history. The economics tab models the cost of AI compute. | +| **Analyze** | `/analyze` | Portfolio tab scores any portfolio 0-100 on AI-power exposure; scenario tab models demand growth, nuclear capacity, and grid stress. | +| **Catalyst Tracker** | `/catalysts` | Earnings dates (live from Yahoo, seeded fallback) plus curated thesis catalysts on a monthly calendar. | +| **Analysis** | `/blog` | Research articles plus the daily Buildout Brief. | -No proprietary data feeds and no scraped paywalled sources. All projections are clearly labeled as such. +A news ticker runs across every dashboard page: a 7-day rolling feed from 8 industry RSS sources (Utility Dive, DCD, World Nuclear News, Power Engineering, POWER Magazine, Latitude Media, DOE, EIA), with NewsData.io as an optional additional tier. ---- +## Data honesty -## Index methodology +The rule across the app: serve real observations, flag estimates, and degrade with an explicit error instead of fabricating a number. -The three headline indices are deterministic functions of their published constituents. Exact formulas, so you can check the math: +- **GPU prices** are recorded from public, keyless marketplace APIs: RunPod (secure and community on-demand pricing) and Vast.ai (median of the cheapest verified on-demand offers). A model with no live source that day records nothing; curated anchor prices are flagged `est.` and charts interpolate across gaps instead of inventing points. +- **Uranium correlation** is computed from 52 weeks of real weekly closes: SRUUF (Sprott Physical Uranium Trust, the closest freely quotable spot proxy) against CCJ and CEG. On fetch failure the card shows an unavailable state, not invented dots. +- **Equity quotes** fall back per ticker to labeled static data when Yahoo throttles; the change column renders "--" rather than a stale number pretending to be live. +- **Keyed sources** respond `{configured: false}` with setup instructions when the key is absent; upstream failures return 502. Nothing is faked on failure. -- **AI Demand** = clamp(52–94, `72 + 1.2 × (NVDA% × 0.40 + TSM% × 0.25 + EQIX% × 0.20 + MU% × 0.15)`) using today's intraday percent changes. It reads how the market is pricing the AI-buildout complex **today**; it does not measure data-center load. -- **Grid Stress** = clamp(52–92, `68 + (VST% × 0.40 + CEG% × 0.35 + EQIX% × 0.25)`). Same construction; EQIX appears in both baskets. It reads power-equity momentum, not reserve margins or LMPs. -- **NPI (Nuclear Power Index)** = `100 × (0.25·CEG + 0.20·VST + 0.15·CCJ + 0.20·NLR + 0.10·uranium spot + 0.10·policy)` as price relatives to Jan 1, 2024 bases, times a 0.9–1.1 policy multiplier from a hand-derived SMR policy score. Weights are judgment calls and labeled as such. +## Data sources -Baselines (72, 68) and clamps are presentation choices that keep the gauges readable; they are disclosed here so nobody mistakes them for measurements. Constituent values are exposed at `/api/kpis`. +| Data | Source | Behavior | +|---|---|---| +| Equity quotes, sparklines, fundamentals (~100 tickers) | Yahoo Finance via `yahoo-finance2` (unofficial, indicative) | Live; per-ticker labeled static fallback | +| GPU rental prices | RunPod GraphQL API + Vast.ai marketplace API (public, keyless) | Live observations recorded daily; no observation means no record | +| Uranium correlation | SRUUF vs CCJ and CEG weekly closes via Yahoo | Live; null (unavailable) on failure | +| US monthly electric output | FRED [`IPG2211A2N`](https://fred.stlouisfed.org/series/IPG2211A2N) at `/api/physical/electricity-output` | Live, daily cache; 502 on failure | +| US48 hourly demand | EIA v2 at `/api/physical/load-hourly` (needs `EIA_API_KEY`) | Live, 30-min cache; `{configured: false}` without a key | +| Industry news | 8 RSS feeds; NewsData.io with an optional key | Live, refreshed hourly | +| Facilities, superclusters, deals, interconnection queue, catalysts | Curated JSON in `server/data/` with per-item sources; some files are machine-written by recorders and a reviewed RSS ingester | Curated, refreshed as announcements land | -**Validation:** the gauges are backtested against physical electricity output (FRED `IPG2211A2N`, 2019–2026, leads 0–3 months) in [docs/INDEX_VALIDATION.md](./docs/INDEX_VALIDATION.md). Result: neither AI Demand nor Grid Stress shows a physical signal, so both are labeled **market sentiment gauges** in the UI, not measurements. Reproduce it yourself: `npm run backtest:indices`. The reconstructed daily series lives in `server/data/index-history.json`. +No proprietary data feeds and no scraped paywalled sources. Projections are labeled as projections. ---- +## Legacy indices -## Stack +The former headline gauges (AI Demand, Grid Stress, NPI) were sentiment formulas over equity moves. They no longer appear on the dashboard; the overview shows measured numbers instead. `/api/kpis` and `/api/index-history` still serve and record them so the published series stays continuous. Methodology and backtest: [docs/INDEX_VALIDATION.md](./docs/INDEX_VALIDATION.md), `npm run backtest:indices`. -- **Frontend** — React 18, TypeScript, Vite, Tailwind, shadcn/ui, Recharts, D3, React Leaflet -- **Backend** — Node 20, Express 5, Drizzle ORM, optional Postgres (in-memory fallback) -- **Data** — `yahoo-finance2`, `rss-parser`, curated JSON datasets in `server/data/` -- **Hosting** — Replit Deployments (autoscale) +## Stack ---- +- **Client**: React 18, TypeScript, Vite, wouter, TanStack Query v5, Tailwind CSS, shadcn/ui (house-modified fork in `client/src/components/ui`; do not regenerate from upstream), Recharts, visx, d3, react-leaflet. +- **Server**: Express 5, single process. Vite middleware in dev, static files from `dist/public` in prod. Port 5000. +- **Persistence**: JSON files in `server/data/` and `content/blog/`. There is no database. +- **Tests**: Node's built-in test runner; 217 tests across server and client lib suites. ## Run locally ```bash npm install cp .env.example .env # set UNSUB_TOKEN_SECRET and ADMIN_API_KEY; the rest is optional -npm run dev +npm run dev # Express + Vite middleware on port 5000 ``` -Both the Express API and the Vite dev server bind to **port 5000** (override with `PORT`). - ```bash -npm run build # production bundle -npm start # serve the built bundle npm run check # typecheck +npm test # node:test suites +npm run build # vite client + esbuild server -> dist/ +npm start # serve the built bundle ``` ---- - ## Configuration | Var | Required | Notes | |---|---|---| | `UNSUB_TOKEN_SECRET` | yes | HMAC key for unsubscribe tokens. `openssl rand -hex 32`. | -| `ADMIN_API_KEY` | yes | Guards `/api/admin/*` and newsletter send. `openssl rand -hex 32`. | -| `RESEND_API_KEY` | no | Syncs subscribers to Resend and enables newsletter sends. Without it, signups only persist to local JSON (ephemeral on autoscale hosts). | -| `EIA_API_KEY` | no | Free key from [eia.gov/opendata](https://www.eia.gov/opendata/register.php). Enables live US48 hourly demand at `/api/physical/load-hourly`. | +| `ADMIN_API_KEY` | yes | Guards `/api/admin/*`, newsletter send, and daily export. `openssl rand -hex 32`. | +| `RESEND_API_KEY` | no | Syncs subscribers to Resend and enables newsletter sends. Without it, signups only persist to local JSON. | +| `EIA_API_KEY` | no | Free key from [eia.gov/opendata](https://www.eia.gov/opendata/register.php). Enables live US48 hourly demand. | | `NEWSDATA_API_KEY` | no | Optional [newsdata.io](https://newsdata.io) key. The 8 RSS feeds work without it. | -| `DATABASE_URL` | no | Postgres connection string. Without it, the app uses in-memory storage. | -| `X_API_*` | no | Four X credentials for the daily auto-poster; dry-run logs locally without them. | - ---- +| `X_API_*` | no | Four X credentials for the weekday auto-poster; it dry-runs and logs locally without them. | +| `X_POSTING_ENABLED` | no | Kill switch for the auto-poster; defaults off (dry-run). | +| `DISABLE_DATACENTER_INGESTER` | no | Set to `1` to disable the 6-hour RSS datacenter ingester. | ## Project layout ``` -client/ React + Vite frontend - src/ - pages/ Route-level components (TiltOverview, Stack, PowerMap, ...) - components/ Shared UI and shadcn primitives - data/ Static config (supply chain graph, ticker metadata) -server/ Express API - routes.ts All HTTP endpoints + RSS / Yahoo fetchers - storage.ts IStorage interface (in-memory + Postgres impls) -shared/ Types and Drizzle schemas shared between client and server +client/src/ + pages/ route-level components, one file per tool + components/ shared UI; components/ui is the modified shadcn fork + data/ static client config (supply chain graph, catalysts, RTOs) + lib/ query client, design tokens, pure helpers (tested) +server/ + routes.ts all HTTP endpoints + *.ts pure modules the routes call (indices, clusters, deals, + gpu-index, gpu-live, gpu-economics, brief, physical, ...) + data/ JSON persistence: hand-curated and machine-written files + __tests__/ node:test suites +content/blog/ articles.json +script/build.ts production build (vite client + esbuild server) +scripts/ backtest and maintenance scripts ``` ---- +There is no `shared/` directory, no ORM, and no external datastore. API payload shapes are declared where they are consumed. + +## Deploy + +Replit autoscale. A push to GitHub does not deploy, and autoscale does not auto-pull: merge, then redeploy manually in Replit (Deployments, then Redeploy). After a redeploy, sanity-check `/api/stack`, `/api/kpis`, and `/api/clusters/metrics`. + +Machine-written JSON (subscribers, price history, ingested facilities) lives on the instance disk, which is ephemeral across redeploys. ## License -MIT — see [LICENSE](./LICENSE). +MIT, see [LICENSE](./LICENSE). Built by [Jack Schwartz](https://github.com/aurph). diff --git a/client/src/App.tsx b/client/src/App.tsx index bfb3628..f31713b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -25,6 +25,7 @@ import Subscribe from "@/pages/Subscribe"; import AdminDatacenters from "@/pages/AdminDatacenters"; import AdminSocial from "@/pages/AdminSocial"; import { NewsTicker } from "@/components/NewsTicker"; +import { initAnalytics, trackPageview } from "@/lib/analytics"; import { useLocation } from "wouter"; import { useState, useEffect, lazy, Suspense } from "react"; import { X, Keyboard } from "lucide-react"; @@ -211,6 +212,15 @@ function App() { const isMarketing = MARKETING_ROUTES.includes(location); + // Privacy-respecting pageviews (GoatCounter); inert unless + // VITE_GOATCOUNTER_CODE is set at build time. + useEffect(() => { + initAnalytics(); + }, []); + useEffect(() => { + trackPageview(location); + }, [location]); + useEffect(() => { const handler = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); diff --git a/client/src/components/Freshness.tsx b/client/src/components/Freshness.tsx index 8ef1824..edb6982 100644 --- a/client/src/components/Freshness.tsx +++ b/client/src/components/Freshness.tsx @@ -5,7 +5,7 @@ * "market closed" (handled per-page where it applies). */ import { useEffect, useState } from "react"; -import { AlertTriangle, Clock, RotateCw } from "lucide-react"; +import { AlertTriangle, RotateCw } from "lucide-react"; function fmtClock(ts: number): string { return new Date(ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" }); @@ -21,23 +21,23 @@ function fmtAge(ageMs: number): string { } /** - * "as of" chip for widget headers. Hover shows the exact timestamp. - * When the data is older than `staleAfterMs` (default: 2x the refresh - * interval), it flips to an amber stale badge - the source missed its - * schedule, which is different from being broken. + * Quiet "as of" text for widget headers; hover shows the exact timestamp. + * Deliberately never escalates to a warning: data age is context for the + * reader, not an alarm - a calendar being an hour old is normal, quotes + * being a night old while markets are closed is normal. Genuinely broken + * sources surface through ErrorState and per-item delayed badges instead. */ export function AsOf({ updatedAt, - intervalMs = 900_000, - staleAfterMs, + intervalMs: _intervalMs = 900_000, className = "", }: { updatedAt: number | undefined; + /** kept for call-site compatibility; age display does not depend on it */ intervalMs?: number; - staleAfterMs?: number; className?: string; }) { - // re-render each 30s so the age and staleness stay live + // re-render each 30s so the age stays live const [, tick] = useState(0); useEffect(() => { const id = setInterval(() => tick((n) => n + 1), 30_000); @@ -46,21 +46,6 @@ export function AsOf({ if (!updatedAt) return null; const age = Date.now() - updatedAt; - const staleAt = staleAfterMs ?? intervalMs * 2; - const stale = age > staleAt; - - if (stale) { - return ( - - - stale · {fmtAge(age)} - - ); - } return ( +
+ +

{title}

+ {about && ( + + + + + +
{about}
+
+
+ )} +
+ {stats} + {right &&
{right}
} +
+ {controls &&
{controls}
} + + ); +} + +/** One inline stat for the header strip: label + mono value. */ +export function HeaderStat({ label, value, valueClass = "text-brand-2" }: { label: string; value: string; valueClass?: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index 4e00482..19406b4 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -83,7 +83,7 @@ export function AppSidebar() { GridTilt logo
diff --git a/client/src/components/neocloud/PriceHistoryChart.tsx b/client/src/components/neocloud/PriceHistoryChart.tsx index 1d5a4a7..87a841d 100644 --- a/client/src/components/neocloud/PriceHistoryChart.tsx +++ b/client/src/components/neocloud/PriceHistoryChart.tsx @@ -52,7 +52,7 @@ export interface PriceHistoryChartProps { const MARGIN = { top: 14, right: 118, bottom: 26, left: 44 }; const GRID_MARGIN = { top: 22, right: 12, bottom: 20, left: 38 }; const OVERLAY_HEIGHT = 380; -const PANEL_HEIGHT = 150; +const PANEL_HEIGHT = 168; const LABEL_GAP = 15; interface TooltipState { @@ -99,7 +99,7 @@ export default function PriceHistoryChart(props: PriceHistoryChartProps) { No price points in this window. {series.length === 0 - ? "No GPUs selected - hit All to reset." + ? "No GPUs selected. Select All to reset." : "Sourced anchors are sparse; the daily recorder widens coverage from here forward. Try 1Y or ALL."}
@@ -310,6 +310,23 @@ function Overlay({ strokeWidth={p.kind === "anchor" ? 0 : 1.4} /> ))} + {/* sparse-series value labels (only when the view is uncrowded) */} + {clipped.length <= 3 && + s.clipped.filter((p) => !p.edge).length <= 4 && + s.clipped.filter((p) => !p.edge).map((p, i) => ( + + ${p.price >= 10 ? p.price.toFixed(0) : p.price.toFixed(2)} + + ))} {/* launch marker: ring on the series' true first point when in window */} {s.launch && s.clipped.some((p) => !p.edge && p.t === s.launch!.t) && ( @@ -444,6 +461,7 @@ function SmallMultiples({ hovered, onHover, estimatedModels, + ranges, }: PriceHistoryChartProps & { clipped: ClippedSeries[]; start: number | null }) { const cols = width >= 1100 ? 5 : width >= 760 ? 4 : width >= 560 ? 3 : 2; const panelW = Math.floor(width / cols); @@ -463,6 +481,7 @@ function SmallMultiples({ dim={hovered !== null && hovered !== s.model} onHover={onHover} est={estimatedModels.has(s.model)} + range={ranges[s.model] ?? null} /> ))}
@@ -478,6 +497,7 @@ function Panel({ dim, onHover, est, + range, }: { series: ClippedSeries; x0: number; @@ -487,16 +507,27 @@ function Panel({ dim: boolean; onHover: (m: string | null) => void; est: boolean; + range: { low: number; high: number } | null; }) { const innerW = Math.max(20, width - GRID_MARGIN.left - GRID_MARGIN.right); const innerH = height - GRID_MARGIN.top - GRID_MARGIN.bottom; const xScale = scaleUtc({ domain: [x0, x1], range: [0, innerW] }); - const yDomain = logDomain(s.clipped.map((p) => p.price)); + // Domain includes the observed marketplace range so the band gives every + // panel real spread context even when the line itself is sparse. + const domainVals = [ + ...s.clipped.map((p) => p.price), + ...(range && range.low > 0 && range.high > 0 ? [range.low, range.high] : []), + ]; + const yDomain = logDomain(domainVals); const yScale = scaleLog({ domain: yDomain, range: [innerH, 0] }); const yTicks = logTicks125(yDomain[0], yDomain[1]).filter((_, i, a) => a.length <= 3 || i % 2 === 0); const ticks = xTicks(x0, x1, innerW).filter((_, i, a) => a.length <= 3 || i % Math.ceil(a.length / 3) === 0); const spans = spansFromClipped(s.clipped); const last = s.clipped[s.clipped.length - 1]; + const realPoints = s.clipped.filter((p) => !p.edge); + // Sparse series annotate every point - the panel reads as data, not gaps. + const labelPoints = realPoints.length <= 5 ? realPoints : []; + const isLive = !est && last.kind === "recorded"; return (
{s.model} ${last.price.toFixed(2)} {est && est.} + {isLive && live}
@@ -527,6 +559,22 @@ function Panel({ ))} + {/* observed marketplace range: real spread context behind the line */} + {range && range.low > 0 && range.high > 0 && ( + + + + mkt ${range.low}–${range.high} + + + )} {spans.map((span, i) => { const st = spanDash(span.quality); return ( @@ -543,7 +591,7 @@ function Panel({ /> ); })} - {s.clipped.filter((p) => !p.edge).map((p) => ( + {realPoints.map((p) => ( ))} + {/* sparse-series value labels: every real point annotated */} + {labelPoints.map((p, i) => ( + + ${p.price >= 10 ? p.price.toFixed(0) : p.price.toFixed(2)} + + ))} diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx deleted file mode 100644 index f38593b..0000000 --- a/client/src/components/ui/dialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client" - -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Dialog = DialogPrimitive.Root - -const DialogTrigger = DialogPrimitive.Trigger - -const DialogPortal = DialogPrimitive.Portal - -const DialogClose = DialogPrimitive.Close - -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName - -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogClose, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} diff --git a/client/src/components/ui/toggle.tsx b/client/src/components/ui/toggle.tsx deleted file mode 100644 index 0da5cb8..0000000 --- a/client/src/components/ui/toggle.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from "react" -import * as TogglePrimitive from "@radix-ui/react-toggle" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const toggleVariants = cva( - "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", - }, - size: { - default: "h-10 px-3 min-w-10", - sm: "h-9 px-2.5 min-w-9", - lg: "h-11 px-5 min-w-11", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -const Toggle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, variant, size, ...props }, ref) => ( - -)) - -Toggle.displayName = TogglePrimitive.Root.displayName - -export { Toggle, toggleVariants } diff --git a/client/src/index.css b/client/src/index.css index 203f8ad..bc8671c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -200,9 +200,7 @@ html { for users who asked for less motion. Data still updates instantly. */ @media (prefers-reduced-motion: reduce) { .ticker-scroll, - .count-up, .live-pulse::before, - .logo-entrance, .carousel-card-enter, .animate-ping, .animate-pulse { @@ -231,14 +229,6 @@ html { background: hsl(var(--muted-foreground)); } -/* Grid background pattern - very faint orange tint, not blue */ -.grid-bg { - background-image: - linear-gradient(rgba(240, 120, 0, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(240, 120, 0, 0.03) 1px, transparent 1px); - background-size: 40px 40px; -} - /* Sidebar nav hover */ .sidebar-nav-link:not(.sidebar-nav-active):hover { background: rgba(255, 255, 255, 0.03); @@ -250,14 +240,6 @@ html { color: #fff; } -/* Glow effects */ -.glow-amber { - box-shadow: 0 0 20px rgba(240, 165, 0, 0.2), 0 0 40px rgba(240, 165, 0, 0.1); -} -.glow-orange { - box-shadow: 0 0 20px rgba(240, 120, 0, 0.2), 0 0 40px rgba(240, 120, 0, 0.1); -} - /* Animated pulse for live indicators */ @keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 1; } @@ -280,15 +262,6 @@ html { padding: 12px !important; } -/* Number ticker animation */ -@keyframes count-up { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} -.count-up { - animation: count-up 0.6s ease-out forwards; -} - /* News ticker scroll animation */ @keyframes ticker-scroll { 0% { transform: translateX(0); } @@ -712,18 +685,6 @@ html { } } -@keyframes logo-wave { - 0% { transform: rotate(0deg) scale(0.8); opacity: 0; } - 40% { transform: rotate(-8deg) scale(1.05); opacity: 1; } - 60% { transform: rotate(4deg) scale(1.02); } - 80% { transform: rotate(-2deg) scale(1); } - 100% { transform: rotate(0deg) scale(1); opacity: 1; } -} - -.logo-entrance { - animation: logo-wave 0.8s ease-out forwards; -} - @keyframes carousel-slide-in { 0% { opacity: 0; transform: translateY(8px); } 100% { opacity: 1; transform: translateY(0); } diff --git a/client/src/lib/__tests__/state-primitives.test.tsx b/client/src/lib/__tests__/state-primitives.test.tsx index 6bd5200..75d6129 100644 --- a/client/src/lib/__tests__/state-primitives.test.tsx +++ b/client/src/lib/__tests__/state-primitives.test.tsx @@ -26,9 +26,10 @@ describe("state primitive snapshots", () => { t.mock.timers.enable({ apis: ["Date"], now: 1_000_000_000 }); snap(t, renderToStaticMarkup()); }); - it("AsOf stale", (t) => { + it("AsOf stays quiet on old data (age is context, not an alarm)", (t) => { t.mock.timers.enable({ apis: ["Date"], now: 1_000_000_000 }); - snap(t, renderToStaticMarkup()); + const html = renderToStaticMarkup(); + snap(t, html); }); it("AsOf hidden when no timestamp", (t) => { snap(t, renderToStaticMarkup()); diff --git a/client/src/lib/__tests__/state-primitives.test.tsx.snapshot b/client/src/lib/__tests__/state-primitives.test.tsx.snapshot index d7709ea..ee31d1b 100644 --- a/client/src/lib/__tests__/state-primitives.test.tsx.snapshot +++ b/client/src/lib/__tests__/state-primitives.test.tsx.snapshot @@ -6,8 +6,8 @@ exports[`state primitive snapshots > AsOf hidden when no timestamp 1`] = ` "" `; -exports[`state primitive snapshots > AsOf stale 1`] = ` -"stale · 45m ago" +exports[`state primitive snapshots > AsOf stays quiet on old data (age is context, not an alarm) 1`] = ` +"as of 45m ago" `; exports[`state primitive snapshots > ErrorState with retry 1`] = ` diff --git a/client/src/lib/analytics.ts b/client/src/lib/analytics.ts new file mode 100644 index 0000000..4f2d11e --- /dev/null +++ b/client/src/lib/analytics.ts @@ -0,0 +1,44 @@ +/** + * Privacy-respecting pageview analytics via GoatCounter: no cookies, no + * cross-site tracking, no personal data. Entirely inert until + * VITE_GOATCOUNTER_CODE is set at build time (the goatcounter.com site code), + * so development and forks send nothing. + * + * SPA note: automatic counting is disabled and every route change is counted + * manually from the router, otherwise only the first page load would count. + */ + +declare global { + interface Window { + goatcounter?: { + no_onload?: boolean; + count?: (opts: { path: string; title?: string }) => void; + }; + } +} + +const CODE: string | undefined = import.meta.env.VITE_GOATCOUNTER_CODE; + +let loaded = false; + +export function initAnalytics(): void { + if (!CODE || loaded || typeof document === "undefined") return; + loaded = true; + window.goatcounter = { no_onload: true }; + const s = document.createElement("script"); + s.async = true; + s.src = "https://gc.zgo.at/count.js"; + s.dataset.goatcounter = `https://${CODE}.goatcounter.com/count`; + document.head.appendChild(s); +} + +/** Count one pageview for the given path. Safe to call when inactive. */ +export function trackPageview(path: string): void { + if (!CODE) return; + // the script may still be loading on the first navigation; retry once + if (window.goatcounter?.count) { + window.goatcounter.count({ path }); + } else { + setTimeout(() => window.goatcounter?.count?.({ path }), 1500); + } +} diff --git a/client/src/lib/dates.ts b/client/src/lib/dates.ts deleted file mode 100644 index 7544783..0000000 --- a/client/src/lib/dates.ts +++ /dev/null @@ -1,39 +0,0 @@ -export function daysUntil(dateStr: string): number { - const target = new Date(dateStr + "T12:00:00"); - const now = new Date(); - now.setHours(12, 0, 0, 0); - return Math.round((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); -} - -export function relativeTime(updatedAt: number | undefined): string { - if (!updatedAt) return "loading"; - const seconds = Math.floor((Date.now() - updatedAt) / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - return `${hours}h ago`; -} - -export function formatIsoAsDateline(iso: string | undefined): string { - if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - return new Intl.DateTimeFormat("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }).format(d); -} - -export function formatTodayLong(): string { - return new Intl.DateTimeFormat("en-GB", { - weekday: "long", - day: "numeric", - month: "long", - year: "numeric", - }).format(new Date()); -} diff --git a/client/src/pages/Analyze.tsx b/client/src/pages/Analyze.tsx index dd07a89..46759f8 100644 --- a/client/src/pages/Analyze.tsx +++ b/client/src/pages/Analyze.tsx @@ -4,11 +4,10 @@ * one screen with URL-persisted tabs (?tab=scenario shareable; old routes * can redirect into a tab). Same pattern as GPU Prices (ToolTabs). */ -import { BarChart3 } from "lucide-react"; import { ToolTabs, useToolTabs, type ToolTab } from "@/components/ToolTabs"; +import { PageHeader } from "@/components/PageHeader"; import PortfolioOverlay from "@/pages/PortfolioOverlay"; import TheTrade from "@/pages/TheTrade"; -import { FONT } from "@/lib/tokens"; export const ANALYZE_TABS: ToolTab[] = [ { id: "portfolio", label: "Portfolio" }, @@ -20,21 +19,12 @@ export default function Analyze() { return (
- {/* Header */} -
-
-
- -

- Analyze -

-
-

- Your inputs, scored against the AI power buildout - portfolio exposure and buildout scenarios. -

- -
-
+ } + />
{tab === "scenario" ? : } diff --git a/client/src/pages/BlogIndex.tsx b/client/src/pages/BlogIndex.tsx index 11f38c2..03cff3e 100644 --- a/client/src/pages/BlogIndex.tsx +++ b/client/src/pages/BlogIndex.tsx @@ -4,6 +4,7 @@ import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Calendar, ArrowRight } from "lucide-react"; +import { PageHeader } from "@/components/PageHeader"; import BriefPage from "@/pages/brief"; interface BlogArticle { @@ -20,21 +21,14 @@ export default function BlogIndex() { }); return ( -
-
- - -
-

Analysis

-

- Research and analysis on the AI power infrastructure thesis. Data-driven, no hype. -

-
+
+ +
{/* Today's read: the daily brief, self-contained (own loading/error state), so it can never blank the post list below. */} diff --git a/client/src/pages/CatalystTracker.tsx b/client/src/pages/CatalystTracker.tsx index 67d1b4d..7fc27eb 100644 --- a/client/src/pages/CatalystTracker.tsx +++ b/client/src/pages/CatalystTracker.tsx @@ -2,11 +2,12 @@ import { useState, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useLocation, Link } from "wouter"; import { - CalendarDays, ChevronLeft, ChevronRight, Clock, + ChevronLeft, ChevronRight, Clock, TrendingUp, ArrowRight, Eye, EyeOff, AlertTriangle, RotateCw, } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { AsOf } from "@/components/Freshness"; +import { PageHeader, HeaderStat } from "@/components/PageHeader"; import { catalystCategoryColors, STAGE_COLORS, @@ -69,8 +70,15 @@ function formatDateFull(dateStr: string): string { }); } -function getStageColor(ticker: string): string { - const stage = SUPPLY_CHAIN_STAGE_MAP[ticker]; +/** + * Stage color from the CLIENT token palette, by stage name first, ticker map + * second. The server payload still carries a legacy stageColor hex (the old + * all-orange family) - it is deliberately ignored so the token palette is + * the single source of truth. + */ +function stageColorOf(e: { stage?: string; ticker: string }): string { + if (e.stage && STAGE_COLORS[e.stage]) return STAGE_COLORS[e.stage]; + const stage = SUPPLY_CHAIN_STAGE_MAP[e.ticker]; return stage ? (STAGE_COLORS[stage] || INK.muted) : INK.muted; } @@ -165,7 +173,7 @@ function CalendarGrid({ {dayItems.map((item, j) => { let color: string = INK.muted; if (item.type === "earnings") { - color = (item as EarningsItem).stageColor || getStageColor((item as EarningsItem).ticker); + color = stageColorOf(item as EarningsItem); } else { color = catalystCategoryColors[(item as CatalystItem).category] || INK.muted; } @@ -213,7 +221,7 @@ function DayDetailCard({ item }: { item: MergedItem }) { return (
@@ -231,7 +239,7 @@ function DayDetailCard({ item }: { item: MergedItem }) {
{e.stage} @@ -239,7 +247,7 @@ function DayDetailCard({ item }: { item: MergedItem }) {