SPB Git
9.7 KB

# 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

bash
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 → admin

# Routes

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

text
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

  1. SSE must not be compressed. Behind the Next rewrite proxy (dev and next start) text/event-stream is gzip-buffered unless the response carries Cache-Control: no-store, no-transform. Without it the browser receives nothing until the connection closes. The mock sets it; FastAPI's /live/stream must too (and any reverse proxy must keep X-Accel-Buffering: no). The web feed has a 45 s watchdog that falls back to polling /live?since= just in case.
  2. /industries/{slug} and /countries/{code} return companies: CompanyCard[] while the list rows use companies: number. The client handles both (IndustryDetailRaw/CountryDetailRaw); a distinct field name (e.g. companies_list) would be cleaner.
  3. /companies/{slug}/jobs — the summary is expected in meta.summary (the page also accepts a top-level summary).
  4. /live — the client accepts both { items: Event[] } and a bare Event[]; please return { items }.
  5. /admin/queue and /admin/reviews — documented as unpaginated { items }; the client also accepts a Page.
  6. Event.importance is treated as 0–1 (values > 1 are read as 0–100). Please keep 0–1 as in the mock.
  7. Event.tags should be de-duplicated server-side (type + surface collide, e.g. pricing/pricing).
  8. /watchlist GET for a fresh token should return 200 { items: [], events: [] } rather than 404 so first-visit renders cleanly (the mock does).
  9. Sitemap: the web asks /sitemap?kind=companies&page=N and expects pages on every response; only indexed = true companies should be listed.
  10. MapBucket.country is expected as ISO-3166 alpha-2 (the map maps world-atlas numeric ids → alpha-2 for hover/click).
  11. CompanyDetail.coverage.historical_coverage is displayed as a percentage (0–100), consistent with the historical_coverage metric.

# Known gaps

  • No Technology tab yet (spec §40 lists it; the API has no technology-signals endpoint) — technology signals surface through TECHNOLOGY events and the developer/documentation surfaces.
  • Alerts are created/deleted but not edited; deliveries are read-only.
  • Admin sensor actions use prompt() for set_interval; set_connector has no UI yet.
  • /api documents rate limits as designed in the spec (120 req/min anonymous); confirm the real values once ratelimit.py lands.
  • The world map uses headquarters clusters from /map; per-office expansions will appear once buckets include them.
Files · d3d1e0c2f18c5edddad5bbf423b88362532bf00c
Menu

Theme