spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1# Market Atlas — Frontend (`apps/web`)23Next.js 16.3 (App Router, React 19.2, Tailwind v4, TypeScript strict). Package `@market-atlas/web`.45## Run67```bash8# dev (API must run on :8391 — `pnpm dev:api` at the repo root)9pnpm --filter @market-atlas/web dev # http://localhost:839010pnpm --filter @market-atlas/web typecheck11pnpm --filter @market-atlas/web build # NEXT_DIST_DIR=.next-build to build while dev runs12pnpm --filter @market-atlas/web start # next start -p 8382 -H 127.0.0.1 (production, behind the API edge)13node apps/web/qa/screens.mjs [BASE_URL] # Playwright sweep: 30 routes × {390,1440} × {dark,light}: status, console errors, overflow, screenshots14```1516Environment (root `.env` is loaded by `next.config.ts`): `API_URL` (server-side base, default `http://127.0.0.1:8391`), `NEXT_PUBLIC_SITE_URL` / `MA_SITE_URL` (canonical URLs, default `https://www.market-atlas.co`), optional `NEXT_PUBLIC_WS_URL` (browser WebSocket URL; derived automatically from `API_URL` in dev, same-origin `/v1/stream` in production), `NEXT_DIST_DIR`.1718## Data flow1920- **Server components** call `api<T>(path)` / `apiEnvelope<T>` / `apiOptional<T>` from `src/lib/api.ts` against `API_URL` (no-store). A 404 from the API becomes Next `notFound()`; other errors bubble to `error.tsx`. `apiOptional` returns `null` for non-critical widgets (health panel, changes, events on the home page).21- **Browser code** always calls same-origin `/v1/...` (`src/lib/client-api.ts`). In dev `next.config.ts` rewrites `/v1/:path*` → `API_URL`; in production the API process is the public edge (`:8380`): it serves `/v1/*` itself and proxies every other path to Next on `127.0.0.1:8382`.22- **Live stream** — `src/lib/stream.ts`: one `WebSocket` per tab (`MarketStreamClient` singleton), reference-counted channel subscriptions (`subscribe` / `unsubscribe` messages), reconnect with jittered backoff, batches parsed into a quote store keyed by instrument id plus an events ring buffer and a tape ring buffer. Listeners are notified at most every 100 ms and only for the keys that changed, so a table never re-renders per tick. Hooks: `useMarketStream(channels)` (keeps channels subscribed while mounted; returns connection state), `useLiveQuote(id)` (`useSyncExternalStore` per instrument), `useLiveEvents(max)`, `useTape(max)`, `useNow(ms)`.23- Components merge SSR quotes with the stream: a streamed value is used only when its `received` timestamp is newer than the server quote's `updated_at` (`LivePrice`, `InstrumentHeader`, `InstrumentStats`, `InstrumentTable` cells). Price cells flash subtly on change (`.flash-up/.flash-down`, 600 ms, disabled under reduced motion).2425## Routes2627| Route | Data | Notes |28| --- | --- | --- |29| `/` | `/v1/stats`, `/v1/markets`, `/v1/events`, `/v1/data-health`, `/v1/changes?window=1h` | hero + telemetry strip (polls stats every 5 s, animated counters), pulse rows, live tape (`tape`), live events (`events:*`), world map, movers, source health, what changed |30| `/live` | `/v1/events?limit=100` + stream `events:*`, `tape` | filters: asset class, country, event types, severity, min confidence, include quotes; pause/resume freezes the list |31| `/markets`, `/stocks`, `/etfs`, `/indices`, `/crypto`, `/forex`, `/rates`, `/commodities` | `/v1/markets`, `/v1/instruments?asset_class=…"ed=1&sort=…` | `ClassPage` (server) → `InstrumentTable` (client, sortable, live via `quotes:class:<CLASS>`); sort/page/q/all in search params |32| `/instruments/[id]` (+ `/stocks/[symbol]`, `/crypto/[symbol]`, `/etfs/…`, `/indices/…`, `/forex/…` redirects) | `/v1/instruments/:id`, `/v1/history/:id`, `/v1/quotes/:id/provenance`, `/v1/events?instrument=`, `/v1/filings?cik=` | sticky live header, lightweight-charts (1D/5D intraday from consensus bars + live updates; 1M…MAX daily), key stats, **Why this price?** = Source Coverage Matrix (refreshes every 5 s: observation type, Δ vs consensus in bp, role votes/confirms/validates/excluded with readable exclusion reasons, comparability class, shared-upstream note), `CoverageBadge` "N / T independent sources · tier · status" in the sticky header, section badge and "Observed by" card (`/v1/coverage/:id`), events, filings, venue, company, aliases, related; JSON-LD `FinancialProduct` |33| `/exchanges`, `/exchanges/[id]` | `/v1/exchanges`, `/v1/exchanges/:id` | map + table; detail: state, next transition, sessions, holidays, breadth, gainers/losers/most active, events |34| `/countries`, `/countries/[code]` | `/v1/countries`, `/v1/countries/:code` | atlas by region; detail: venues, indices, rates, FX pairs, equities, events |35| `/events`, `/events/[id]`, `/halts`, `/filings` | `/v1/events`, `/v1/events/:id`, `/v1/filings` | server-filtered tables (type, severity, instrument, country, before-cursor); halts page streams `events:type:TRADING_HALT` |36| `/sources`, `/connectors`, `/data-health`, `/status` | `/v1/sources`, `/v1/connectors`, `/v1/data-health`, `/v1/status` | provenance directory (rights + real-time badges, **role** contributor/validator, "likely shares an upstream with …", no endpoints), operations table, health with 48 h sparklines and incidents, public status |37| `/coverage` (`?tier=A|B|C|D&asset_class=`) | `/v1/coverage` | **Source Mesh v2**: redundancy histogram (≥5/≥3/≥2/single), weighted score, tier targets & attainment, by-asset-class table, **source expansion queue** with tier/class pill filters, likely shared upstreams (lineage) |38| `/search?q=` + ⌘K palette | `/v1/search` | grouped results; palette debounced 140 ms, keyboard navigation |39| `/compare?ids=&resolution=` | `/v1/compare` | rebased lines (lightweight-charts), stats table, correlation matrix, instrument picker via search |40| `/methodology`, `/developers`, `/licensing` | static (+ `/v1/sources` for attributions) | copy consistent with `apps/api/src/core/consensus.ts` |41| `/admin` | `/v1/admin/*` with `x-ma-admin-token` from `localStorage["ma-admin-token"]` | overview, connectors (pause/resume/restart/test, detail with health history/fingerprints/schema-change ack/recent observations/state), schema changes, divergence, storage, discovery probe |42| `robots.ts`, `sitemap.ts` (segmented: static, exchanges, countries, per asset class), `manifest.ts`, `icon.svg`, `not-found.tsx`, `error.tsx`, segment `loading.tsx` (markets/events/filings) | | root `loading.tsx` deliberately absent so `notFound()` returns a real 404 status |4344## Components4546- `components/layout`: `SiteHeader` (desktop nav, ⌘K button, stream indicator, theme toggle, market clock strip NY/London/Frankfurt/Tokyo/HK/Sydney with session dots), `MobileTabBar` (Markets · Live · Search · Events · World), `SiteFooter` (disclaimer, attributions), `SearchProvider`/`SearchDialog`, `ThemeToggle` (persists `ma-theme`; `THEME_SCRIPT` applies it before paint), `LogoMark`/`Wordmark`.47- `components/ui`: `StatusBadge` (data status / connector state / market state / severity tones), `RightsBadge`, `LivePrice`/`ChangeCell`, `FreshnessLabel` ("Updated 320 ms ago" · "Delayed 15 min · 12s ago" · "At close · Fri 16:00 EDT" · "End of day · Sep 11" · "Stale · last update 4h ago" · "Withheld"), `RelativeTime`, `ConfidenceMeter`, `DataTable` (client, sortable, sticky header, responsive hide), `Sparkline`, `Page`/`PageHeader`/`Section`/`Stat`/`Kv`/`Pill`/`Empty`.48- `components/market`: `TelemetryStrip`, `PulseRow`, `LiveTape`, `LiveEvents`, `EventRow`, `LiveFeed`, `InstrumentTable`, `ClassPage`, `InstrumentHeader`, `InstrumentStats`, `PriceChart`, `ProvenancePanel` (coverage matrix), `CoverageBadge`, `RedundancyPanel` (homepage Source Redundancy block from `/v1/stats.coverage`), `CompareView`, `WorldMap` (d3-geo Natural Earth + world-atlas countries-110m, loaded lazily), `HealthPanel`, `ChangesPanel`.49- `components/admin/AdminConsole`.5051## Conventions5253- Design tokens live in `globals.css` (`--canvas/--surface/--ink/--rule/--accent/--positive/--negative/--warning/--stale`, light + dark on `html[data-theme]`); Tailwind utilities map through `@theme inline` (`bg-surface`, `text-ink-2`, `border-rule`…). Numbers use `.mono`/`.tnum`. Dense tables use `.table-dense`.54- Formatting (`src/lib/format.ts`): price precision by magnitude and asset class (FX 4–5 decimals, yields `x.xx%`, crypto sub-cent), compact volumes, signed percents with `−`, `toMs()` parses ISO / epoch / Postgres text timestamps (`2026-09-11 19:50:00-04`).55- Never present stale as live: `data_status` drives every badge and freshness label; `withheld` quotes render provenance but no value.56- Responsive grids must declare base columns (`grid grid-cols-1 [&>*]:min-w-0 lg:grid-cols-…`) — implicit `auto` columns caused horizontal overflow at 390 px. QA script asserts `scrollWidth ≤ clientWidth`.57- Server components never import `stream.ts`; client components never import `lib/api.ts` (`server-only`).5859## Source Mesh v2 (2026-09-13)6061- `lib/coverage.ts`: labels for observation types (TRADE, QUOTE, OFFICIAL_FIX, EOD_CLOSE, STABLECOIN_PROXY…), comparability classes (LIVE / FIX / EOD), `roleOf()` (votes · confirms · validates · excluded), readable `reasonLabel()` (stale, not comparable, temporal mismatch, validation only, validator disagrees, outlier), tier labels/targets, `formatDeltaBps` / `deltaTone`.62- `ConfidenceMeter` accepts `proxies` / `validators` and shows a small `+N` marker when a quote is confirmed by proxies or validators (instrument tables, header).63- Validator contributions arrive with `value: null` (withheld) and are rendered as "— validator" with agrees/disagrees from `delta_bps`; they are never shown as values.64- Homepage: `RedundancyPanel` (prominent) + mesh explainer; footer/sitemap/header include `/coverage`.65