Company Atlas — web app (apps/web)
Next 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4, Geist self-hosted. SSR everywhere it makes sense
(ISR 60–3600 s for public aggregates, no-store for live/owner/admin), client components only for the live pieces
(SSE feed, counters, ⌘K search, drawer, watchlist, admin). Ports: dev 8370 → API 8371; prod 8360 → API
127.0.0.1:8361 (API_URL). The browser never talks to FastAPI directly: /api/v1/* and /health are rewritten by
next.config.ts.
Run
pnpm install # repo root
node apps/web/qa/mock-api.mjs # contract-shaped mock API on :8371 (dev/QA only; admin token dev-admin-token)
pnpm dev:web # http://localhost:8370 (reads ../../.env; API_URL defaults to :8371)
pnpm -r typecheck && pnpm build # apps/web/.next (NEXT_DIST_DIR overrides the output dir)
pnpm --filter @company-atlas/web start # :8360, expects API_URL
# QA (Playwright from ~/Desktop/uqo-eval): screenshots in apps/web/qa/screens/ (git-ignored)
node apps/web/qa/screens.mjs [BASE] [API] # every route × 390/1440 × dark/light; console errors, 404/5xx, overflow, tap targets, counters, SSE
node apps/web/qa/flows.mjs [BASE] # live feed → drawer → company → timeline filter → compare → watchlist → ⌘K → adminRoutes
| Route | Rendering | Data |
|---|---|---|
/ |
ISR 60 s + client live counters/feed | /pulse, /live, /index, /signals, /stats |
/live (?event_type&min_importance&min_confidence&country&industry) |
dynamic + SSE /live/stream (polling fallback + 45 s watchdog) |
/live, /countries, /industries |
/companies (filters, sort, search, pagination) |
ISR 120 s | /companies?sparkline=1 |
/company/[slug] (`?tab=overview |
timeline | signals |
/company/compare?companies=a,b,c |
ISR 120 s + client picker (/search/suggest) |
/companies/compare |
/events, /events/[id] |
ISR 60/120 s | /events, /events/summary, /events/{id} (+ /companies/{slug}/events) |
/change/[id], /sensor/[id], /snapshot/[id], /snapshot/[id]/diff/[other] |
ISR (noindex) | provenance endpoints |
/rankings?kind&window&country&industry |
ISR 120 s | /rankings |
/industry, /industry/[slug], /country, /country/[code] |
ISR 300 s | atlases + /map |
/search?q= (+ /ask panel when the query reads like a question) |
dynamic | /search, /ask |
/watchlist |
client (owner token) | /watchlist, /alerts, /alerts/deliveries |
/system, /methodology, /about, /api, /bot |
ISR / static | /system, /stats, /stats/history, /methodology |
/admin, /admin/[module] (overview, connectors, sensors, companies, failures, queue, llm, reviews, quality, costs) |
client, token in localStorage → X-CA-Admin-Token |
/admin/* |
robots.txt, sitemap.xml (index) + /sitemaps/sitemap/<id>.xml (shards via generateSitemaps), manifest.webmanifest, opengraph-image, icon.svg, apple-icon, icon-512 |
metadata routes | /sitemap?kind&page, /stats |
Not-found: not-found.tsx (dynamic segments check existence in their layout.tsx/page and call notFound()); error.tsx
re-applies the theme. No root loading.tsx (Next 16 soft-404 gotcha).
Code map
src/lib types.ts (docs/API.md mirror) · api.ts (server fetch, ApiError, safe/orNull) · client-api.ts (same-origin, owner/admin)
format.ts · site.ts (names, nav, routes) · event-styles.ts (type → hue/label/subtype wording) · countries.ts (ISO)
owner.ts (X-CA-Owner-Token + local watched mirror) · admin.ts / admin-modules.ts · params.ts · prepaint.ts · fonts.ts
src/components layout/ (header, mobile tab bar, ⌘K dialog, theme, density, footer) · ui/ (section/stat/empty, badges, tabs, sheet,
pagination, key-value, live dot/ago, skeleton) · charts/ (Sparkline, LineChart, Bars/Columns, Heatmap, WorldMap)
events/ (row/list, drawer + context, evidence, filters, diff viewer) · live/ (SSE feed, counters)
company/ (header + density strip, metric tiles, table/cards/mini list, tab panels, compare picker, watch button,
watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (mark, logo)
qa/ mock-api.mjs · screens.mjs · flows.mjs · screens/ (output, git-ignored)Design tokens live at the top of src/app/globals.css (canvas/surface/ink/rule/accent/positive/warning/danger/live,
per-event-type hues --ev-*, sensor tiers, series, map, brand plate, density variables) and are exposed to Tailwind through
@theme inline. Both themes are hand-tuned; default follows the system and lib/prepaint.ts sets data-theme before paint.
Careful language, enforced in the UI
no_longer_listed → "no longer listed"; job decreases say "listings are no longer visible … not evidence of layoffs";
products "no longer listed in the public catalog"; leadership "no longer listed on the monitored leadership page"; signals
carry a signal chip and confidence; every event shows confidence_label, origin (llm/hybrid show model + prompt version),
sources with detection times and a link to /change/[id]; retracted events stay visible, struck through, and are excluded
from counts. Missing metrics render as a dash with "not enough monitored evidence" — never 0. Empty states: "No monitored
evidence available yet." / "Last successfully checked …".
QA results (2026-09-12, mock API)
qa/screens.mjs: 178 checks (44 routes × 390/1440 × dark/light + counters + SSE) — all 200/404 as expected, zero console
errors, zero 404/5xx sub-requests, zero horizontal overflow, zero interactive controls under 40 px on mobile, homepage
counters within the /stats window, SSE prepends a new row on /live. qa/flows.mjs: 15/15 steps pass at 390 and 1440
(live feed → drawer → company → timeline filter → compare picker → watch → watchlist + alert rule → ⌘K → admin).
pnpm -r typecheck clean; pnpm build green (27 static pages + dynamic routes, sitemap shards prerendered). Screenshots reviewed by eye (home, live, company tabs,
compare, events, event detail, change diff, sensor, snapshot diff, rankings, industry/country, search + ask, watchlist,
system, admin, 404) in both themes.
Fixed during QA: grid items letting scroll containers widen the page on mobile ([class*='grid-cols'] > * { min-width: 0 });
sub-40 px .btn-sm/chips/compare-remove buttons on touch; duplicate React keys (world-atlas features with repeated ids,
duplicated event tags); OG image satori flex rule; /sitemap.xml conflict with generateSitemaps (shards moved under
/sitemaps/); ADMIN_MODULES imported from a 'use client' file into a server page; a valueOf prop name colliding with
Object.prototype; hiring-momentum value wrapping in the narrow mobile metric tile; useSearchParams in the homepage live feed without a Suspense
boundary (build-time prerender error); SSE frames buffered by gzip behind the Next proxy (see contract note 1).
Contract notes / questions for the API team
- SSE must not be compressed. Behind the Next rewrite proxy (dev and
next start)text/event-streamis gzip-buffered unless the response carriesCache-Control: no-store, no-transform. Without it the browser receives nothing until the connection closes. The mock sets it; FastAPI's/live/streammust too (and any reverse proxy must keepX-Accel-Buffering: no). The web feed has a 45 s watchdog that falls back to polling/live?since=just in case. /industries/{slug}and/countries/{code}returncompanies: CompanyCard[]while the list rows usecompanies: number. The client handles both (IndustryDetailRaw/CountryDetailRaw); a distinct field name (e.g.companies_list) would be cleaner./companies/{slug}/jobs— the summary is expected inmeta.summary(the page also accepts a top-levelsummary)./live— the client accepts both{ items: Event[] }and a bareEvent[]; please return{ items }./admin/queueand/admin/reviews— documented as unpaginated{ items }; the client also accepts aPage.Event.importanceis treated as 0–1 (values > 1 are read as 0–100). Please keep 0–1 as in the mock.Event.tagsshould be de-duplicated server-side (type + surface collide, e.g.pricing/pricing)./watchlistGET for a fresh token should return200 { items: [], events: [] }rather than 404 so first-visit renders cleanly (the mock does).- Sitemap: the web asks
/sitemap?kind=companies&page=Nand expectspageson every response; onlyindexed = truecompanies should be listed. MapBucket.countryis expected as ISO-3166 alpha-2 (the map maps world-atlas numeric ids → alpha-2 for hover/click).CompanyDetail.coverage.historical_coverageis displayed as a percentage (0–100), consistent with thehistorical_coveragemetric.
Known gaps
- No Technology tab yet (spec §40 lists it; the API has no technology-signals endpoint) — technology signals surface through
TECHNOLOGYevents and the developer/documentation surfaces. - Alerts are created/deleted but not edited; deliveries are read-only.
- Admin sensor actions use
prompt()forset_interval;set_connectorhas no UI yet. /apidocuments rate limits as designed in the spec (120 req/min anonymous); confirm the real values onceratelimit.pylands.- The world map uses headquarters clusters from
/map; per-office expansions will appear once buckets include them.