SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%

web: Next 16 app (44 routes, live SSE feed, company terminal, compare, rankings, admin, world map, QA harness + mock API)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent 366d199

103 changed files +13,202 −0

added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
added apps/web/next.config.ts +61 −0
@@ -0,0 +1,61 @@
1 +import type { NextConfig } from 'next';
2 +import { existsSync } from 'node:fs';
3 +import path from 'node:path';
4 +
5 +// Monorepo: the single `.env` lives at the repository root; Next only reads the app directory by default.
6 +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) {
7 + if (existsSync(candidate)) {
8 + try {
9 + process.loadEnvFile(candidate);
10 + } catch {
11 + /* ignore malformed env */
12 + }
13 + }
14 +}
15 +
16 +// Dev: FastAPI (or qa/mock-api.mjs) on :8371. Production sets API_URL=http://127.0.0.1:8361 in the mld manifest.
17 +const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8371').replace(/\/$/, '');
18 +
19 +const nextConfig: NextConfig = {
20 + // NEXT_DIST_DIR lets parallel dev servers / builds use separate output dirs (default .next).
21 + distDir: process.env.NEXT_DIST_DIR || '.next',
22 + reactStrictMode: true,
23 + poweredByHeader: false,
24 + allowedDevOrigins: ['127.0.0.1', 'localhost'],
25 + outputFileTracingRoot: path.resolve(__dirname, '../..'),
26 + experimental: {
27 + optimizePackageImports: ['lucide-react'],
28 + },
29 + // Browser-side fetches go to the same origin; the FastAPI service is loopback-only.
30 + async rewrites() {
31 + return [
32 + { source: '/api/v1/:path*', destination: `${API_URL}/api/v1/:path*` },
33 + { source: '/health', destination: `${API_URL}/health` },
34 + ];
35 + },
36 + async headers() {
37 + const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=120, stale-while-revalidate=1800' };
38 + const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' };
39 + return [
40 + {
41 + source: '/(.*)',
42 + headers: [
43 + { key: 'X-Content-Type-Options', value: 'nosniff' },
44 + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
45 + { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
46 + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
47 + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
48 + ],
49 + },
50 + { source: '/company/:path*', headers: [PUBLIC_CACHE] },
51 + { source: '/industry/:path*', headers: [PUBLIC_CACHE] },
52 + { source: '/country/:path*', headers: [PUBLIC_CACHE] },
53 + { source: '/api/:path*', headers: [NO_STORE] },
54 + { source: '/live', headers: [NO_STORE] },
55 + { source: '/watchlist', headers: [NO_STORE] },
56 + { source: '/admin/:path*', headers: [NO_STORE] },
57 + ];
58 + },
59 +};
60 +
61 +export default nextConfig;
added apps/web/package.json +42 −0
@@ -0,0 +1,42 @@
1 +{
2 + "name": "@company-atlas/web",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "dev": "next dev -p 8370",
7 + "build": "next build",
8 + "start": "next start -p 8360 -H 0.0.0.0",
9 + "typecheck": "tsc -p tsconfig.json --noEmit",
10 + "qa": "node qa/screens.mjs",
11 + "qa:flows": "node qa/flows.mjs",
12 + "mock": "node qa/mock-api.mjs"
13 + },
14 + "dependencies": {
15 + "d3-array": "^3.2.4",
16 + "d3-geo": "^3.1.1",
17 + "d3-scale": "^4.0.2",
18 + "d3-shape": "^3.2.0",
19 + "geist": "^1.5.1",
20 + "lucide-react": "^1.0.0",
21 + "next": "16.3.4",
22 + "react": "19.2.8",
23 + "react-dom": "19.2.8",
24 + "server-only": "^0.0.1",
25 + "topojson-client": "^3.1.0",
26 + "world-atlas": "^2.0.2"
27 + },
28 + "devDependencies": {
29 + "@tailwindcss/postcss": "^4",
30 + "@types/d3-array": "^3.2.1",
31 + "@types/d3-geo": "^3.1.0",
32 + "@types/d3-scale": "^4.0.9",
33 + "@types/d3-shape": "^3.1.7",
34 + "@types/node": "^24.0.0",
35 + "@types/react": "^19",
36 + "@types/react-dom": "^19",
37 + "@types/topojson-client": "^3.1.5",
38 + "@types/topojson-specification": "^1.0.5",
39 + "tailwindcss": "^4",
40 + "typescript": "^5.9.3"
41 + }
42 +}
added apps/web/postcss.config.mjs +7 −0
@@ -0,0 +1,7 @@
1 +const config = {
2 + plugins: {
3 + '@tailwindcss/postcss': {},
4 + },
5 +};
6 +
7 +export default config;
added apps/web/qa/flows.mjs +119 −0
@@ -0,0 +1,119 @@
1 +/**
2 + * QA flows (Playwright): live feed → event drawer → company → timeline filter → compare → watchlist add, plus ⌘K search
3 + * and the admin token gate. Mobile (390) and desktop (1440). Fails on console errors.
4 + * Run: node qa/flows.mjs [BASE_URL]
5 + */
6 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
7 +import { mkdirSync } from 'node:fs';
8 +
9 +const BASE = process.argv[2] ?? 'http://localhost:8370';
10 +const OUT = new URL('./screens/', import.meta.url).pathname;
11 +mkdirSync(OUT, { recursive: true });
12 +const browser = await chromium.launch();
13 +let failures = 0;
14 +const step = (ok, msg) => {
15 + if (!ok) failures++;
16 + console.log(`${ok ? 'OK ' : 'FAIL'} ${msg}`);
17 +};
18 +
19 +for (const width of [390, 1440]) {
20 + const mobile = width < 768;
21 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, isMobile: mobile, hasTouch: mobile, colorScheme: 'dark' });
22 + const page = await ctx.newPage();
23 + const errors = [];
24 + page.on('pageerror', (e) => errors.push(String(e)));
25 + page.on('console', (m) => m.type() === 'error' && !/favicon/.test(m.text()) && errors.push(m.text()));
26 + const shot = (n) => page.screenshot({ path: `${OUT}flow-${width}-${n}.png` }).catch(() => undefined);
27 + console.log(`\n— flows @ ${width} —`);
28 +
29 + // 1. live feed → event drawer
30 + await page.goto(`${BASE}/live`, { waitUntil: 'networkidle' });
31 + const rows = await page.locator('[data-live-feed] [data-event-id]').count();
32 + step(rows > 0, `live feed renders ${rows} rows`);
33 + await page.locator('[data-live-feed] [data-open-event]').first().click();
34 + await page.waitForSelector('[data-event-drawer]', { timeout: 5000 }).catch(() => undefined);
35 + const drawer = await page.locator('[data-event-drawer]').count();
36 + step(drawer === 1, 'event drawer opens with evidence');
37 + const hasSources = await page.locator('[data-event-drawer] a[href^="http"]').count();
38 + step(hasSources > 0, `drawer links to ${hasSources} source URL(s)`);
39 + await shot('1-drawer');
40 +
41 + // 2. drawer → company page
42 + const companyLink = page.locator('[data-event-drawer] a[href^="/company/"]').first();
43 + const companyHref = await companyLink.getAttribute('href');
44 + await companyLink.click();
45 + await page.waitForURL(/\/company\//, { timeout: 15000 });
46 + await page.waitForLoadState('networkidle');
47 + step(!!companyHref && page.url().includes(companyHref), `navigated to company ${companyHref}`);
48 + step((await page.locator('[data-metric-tiles]').count()) === 1, 'metric tiles rendered');
49 + step((await page.locator('[data-density-strip]').count()) === 1, 'data-density strip rendered');
50 + await shot('2-company');
51 +
52 + // 3. timeline tab + filter
53 + await page.getByRole('tab', { name: 'Timeline' }).click();
54 + await page.waitForURL(/tab=timeline/, { timeout: 15000 });
55 + await page.waitForLoadState('networkidle');
56 + await page.getByRole('tab', { name: 'Jobs', exact: true }).nth(0).click().catch(() => undefined);
57 + const jobsFilter = page.locator('[role="tablist"][aria-label="Timeline filter"] a', { hasText: 'Jobs' });
58 + await jobsFilter.click();
59 + await page.waitForURL(/filter=jobs/, { timeout: 15000 });
60 + await page.waitForLoadState('networkidle');
61 + const badges = await page.locator('[role="tabpanel"] [data-event-type]').evaluateAll((els) => [...new Set(els.map((e) => e.getAttribute('data-event-type')))]);
62 + step(badges.every((b) => b === 'hiring') , `timeline filter=jobs shows only hiring events (${badges.join(',') || 'none'})`);
63 + await shot('3-timeline');
64 +
65 + // 4. compare
66 + await page.goto(`${BASE}/company/compare?companies=stripe,adyen`, { waitUntil: 'networkidle' });
67 + await page.locator('[data-compare-input]').fill('block');
68 + await page.waitForSelector('[data-compare-picker] [role="option"]', { timeout: 5000 });
69 + await page.locator('[data-compare-picker] [role="option"]').first().click();
70 + await page.waitForURL(/companies=stripe,adyen,block/, { timeout: 15000 });
71 + await page.waitForLoadState('networkidle');
72 + step((await page.locator('table thead th').count()) >= 4, 'compare table has three company columns');
73 + await shot('4-compare');
74 +
75 + // 5. watchlist add
76 + await page.goto(`${BASE}/company/stripe`, { waitUntil: 'networkidle' });
77 + const watch = page.locator('[data-watch="stripe"]');
78 + await watch.click();
79 + await page.waitForTimeout(800);
80 + step((await watch.getAttribute('aria-pressed')) === 'true', 'watch button toggles to Watching');
81 + await page.goto(`${BASE}/watchlist`, { waitUntil: 'networkidle' });
82 + await page.waitForTimeout(800);
83 + step((await page.locator('[data-watchlist] a[href="/company/stripe"]').count()) > 0, 'watchlist page lists Stripe');
84 + await page.locator('[data-alert-form] input[aria-label="Rule name"]').fill('QA pricing rule');
85 + await page.locator('[data-alert-form] button[type="submit"]').click();
86 + await page.waitForTimeout(800);
87 + step((await page.getByText('QA pricing rule').count()) > 0, 'alert rule created');
88 + await shot('5-watchlist');
89 +
90 + // 6. ⌘K search
91 + await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
92 + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k');
93 + await page.waitForSelector('[data-palette-input]', { timeout: 5000 });
94 + await page.locator('[data-palette-input]').fill('stri');
95 + await page.waitForSelector('[data-palette-row]', { timeout: 5000 });
96 + await page.keyboard.press('Enter');
97 + await page.waitForURL(/\/company\/stripe|\/search/, { timeout: 15000 });
98 + step(/\/company\/stripe|\/search/.test(page.url()), `⌘K search navigates (${new URL(page.url()).pathname})`);
99 + await shot('6-search');
100 +
101 + // 7. admin gate
102 + await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
103 + await page.locator('[data-admin-token]').fill('dev-admin-token');
104 + await page.locator('[data-admin-token]').press('Enter');
105 + await page.waitForSelector('h1:has-text("Overview")', { timeout: 10000 }).catch(() => undefined);
106 + await page.waitForLoadState('networkidle');
107 + step((await page.locator('h1:has-text("Overview")').count()) === 1, 'admin overview renders after token');
108 + await page.goto(`${BASE}/admin/sensors?filter=failing`, { waitUntil: 'networkidle' });
109 + await page.waitForTimeout(800);
110 + step((await page.locator('table tbody tr').count()) > 0, 'admin sensors table renders');
111 + await shot('7-admin');
112 +
113 + step(errors.length === 0, `no console errors (${errors.length})${errors.length ? ' :: ' + errors[0].slice(0, 160) : ''}`);
114 + await ctx.close();
115 +}
116 +
117 +await browser.close();
118 +console.log(failures ? `\n${failures} failure(s)` : '\nall flows OK');
119 +process.exit(failures ? 1 : 0);
added apps/web/qa/mock-api.mjs +1353 −0
@@ -0,0 +1,1353 @@
1 +#!/usr/bin/env node
2 +/**
3 + * Company Atlas — mock API for development and QA (NOT used in production).
4 + * Dependency-free Node HTTP server on :8371 serving contract-shaped sample data for every endpoint in docs/API.md:
5 + * ~40 companies across countries/industries, events of every type with careful wording, metrics, rankings, industries,
6 + * countries, signals, trends, map buckets, sensors/snapshots/changes/diff, in-memory watchlists/alerts, SSE live stream
7 + * (one event every ~4 s) and admin payloads. Data is generated from a fixed seed so SSR and client agree.
8 + *
9 + * Run: node apps/web/qa/mock-api.mjs [port]
10 + */
11 +import { createServer } from 'node:http';
12 +
13 +const PORT = Number(process.argv[2] ?? process.env.PORT ?? 8371);
14 +const NOW = Date.now();
15 +const DAY = 86_400_000;
16 +
17 +// ------------------------------------------------------------------------------------------------ deterministic random
18 +let seed = 20260912;
19 +function rnd() {
20 + seed |= 0;
21 + seed = (seed + 0x6d2b79f5) | 0;
22 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
23 + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
24 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
25 +}
26 +const ri = (a, b) => a + Math.floor(rnd() * (b - a + 1));
27 +const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
28 +const chance = (p) => rnd() < p;
29 +const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
30 +let idc = 1000;
31 +function id(prefix) {
32 + idc += 7;
33 + let n = idc * 9973 + 12345;
34 + let s = '';
35 + for (let i = 0; i < 16; i++) {
36 + s = B32[(n + i * 7) % 32] + s;
37 + n = Math.floor(n / 3) + i * 131;
38 + }
39 + return `${prefix}_${s}`;
40 +}
41 +const iso = (t) => new Date(t).toISOString();
42 +const day = (t) => new Date(t).toISOString().slice(0, 10);
43 +const slugify = (s) =>
44 + s
45 + .toLowerCase()
46 + .replace(/&/g, ' and ')
47 + .replace(/[^a-z0-9]+/g, '-')
48 + .replace(/^-|-$/g, '');
49 +const r1 = (x) => Math.round(x * 10) / 10;
50 +const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
51 +
52 +// ------------------------------------------------------------------------------------------------ reference data
53 +const INDUSTRIES = [
54 + ['fintech', 'Fintech', 'financial-services'],
55 + ['financial-services', 'Financial services', null],
56 + ['banking', 'Banking', 'financial-services'],
57 + ['software', 'Software', 'technology'],
58 + ['technology', 'Technology', null],
59 + ['artificial-intelligence', 'Artificial intelligence', 'technology'],
60 + ['semiconductors', 'Semiconductors', 'technology'],
61 + ['cloud-infrastructure', 'Cloud infrastructure', 'technology'],
62 + ['e-commerce', 'E-commerce', 'retail'],
63 + ['retail', 'Retail', null],
64 + ['automotive', 'Automotive', 'manufacturing'],
65 + ['manufacturing', 'Manufacturing', null],
66 + ['aerospace', 'Aerospace', 'manufacturing'],
67 + ['energy', 'Energy', null],
68 + ['renewable-energy', 'Renewable energy', 'energy'],
69 + ['healthcare', 'Healthcare', null],
70 + ['biotech', 'Biotech', 'healthcare'],
71 + ['pharmaceuticals', 'Pharmaceuticals', 'healthcare'],
72 + ['telecom', 'Telecom', null],
73 + ['media', 'Media', null],
74 + ['logistics', 'Logistics', null],
75 + ['insurance', 'Insurance', 'financial-services'],
76 + ['real-estate', 'Real estate', null],
77 + ['consumer', 'Consumer goods', null],
78 + ['cybersecurity', 'Cybersecurity', 'technology'],
79 +];
80 +const IND = Object.fromEntries(INDUSTRIES.map(([slug, name, parent]) => [slug, { slug, name, parent_slug: parent }]));
81 +
82 +const COUNTRY_META = {
83 + US: ['United States', 'North America', 39.8, -98.6],
84 + CA: ['Canada', 'North America', 56.1, -106.3],
85 + GB: ['United Kingdom', 'Europe', 55.4, -3.4],
86 + DE: ['Germany', 'Europe', 51.2, 10.4],
87 + FR: ['France', 'Europe', 46.2, 2.2],
88 + NL: ['Netherlands', 'Europe', 52.1, 5.3],
89 + SE: ['Sweden', 'Europe', 60.1, 18.6],
90 + CH: ['Switzerland', 'Europe', 46.8, 8.2],
91 + IE: ['Ireland', 'Europe', 53.4, -8.2],
92 + ES: ['Spain', 'Europe', 40.5, -3.7],
93 + JP: ['Japan', 'Asia', 36.2, 138.3],
94 + KR: ['South Korea', 'Asia', 35.9, 127.8],
95 + IN: ['India', 'Asia', 20.6, 79.0],
96 + SG: ['Singapore', 'Asia', 1.35, 103.8],
97 + AU: ['Australia', 'Oceania', -25.3, 133.8],
98 + BR: ['Brazil', 'South America', -14.2, -51.9],
99 + MX: ['Mexico', 'North America', 23.6, -102.6],
100 + AE: ['United Arab Emirates', 'Middle East', 23.4, 53.8],
101 + IL: ['Israel', 'Middle East', 31.0, 34.9],
102 + ZA: ['South Africa', 'Africa', -30.6, 22.9],
103 + NG: ['Nigeria', 'Africa', 9.1, 8.7],
104 + TW: ['Taiwan', 'Asia', 23.7, 121.0],
105 + CN: ['China', 'Asia', 35.9, 104.2],
106 + FI: ['Finland', 'Europe', 61.9, 25.7],
107 +};
108 +
109 +// [name, domain, country, city, lat, lon, industries, public, ticker, exchange, founded, employees_band, importance, description]
110 +const SEED = [
111 + ['Stripe', 'stripe.com', 'US', 'South San Francisco', 37.65, -122.4, ['fintech', 'software'], false, null, null, 2010, '5,001–10,000', 92, 'Payments infrastructure for the internet.'],
112 + ['Adyen', 'adyen.com', 'NL', 'Amsterdam', 52.37, 4.9, ['fintech'], true, 'ADYEN', 'Euronext', 2006, '1,001–5,000', 84, 'Global payments platform for enterprises.'],
113 + ['Block', 'block.xyz', 'US', 'Oakland', 37.8, -122.27, ['fintech'], true, 'XYZ', 'NYSE', 2009, '10,001+', 83, 'Economic empowerment tools: Square, Cash App, TIDAL.'],
114 + ['Shopify', 'shopify.com', 'CA', 'Ottawa', 45.42, -75.7, ['e-commerce', 'software'], true, 'SHOP', 'TSX', 2006, '5,001–10,000', 90, 'Commerce platform for merchants of every size.'],
115 + ['Lightspeed', 'lightspeedhq.com', 'CA', 'Montréal', 45.5, -73.57, ['software', 'retail'], true, 'LSPD', 'TSX', 2005, '1,001–5,000', 62, 'Point-of-sale and commerce platform.'],
116 + ['Wealthsimple', 'wealthsimple.com', 'CA', 'Toronto', 43.65, -79.38, ['fintech'], false, null, null, 2014, '1,001–5,000', 58, 'Investing, spending and saving app.'],
117 + ['Cohere', 'cohere.com', 'CA', 'Toronto', 43.65, -79.38, ['artificial-intelligence'], false, null, null, 2019, '201–500', 74, 'Enterprise AI models and retrieval.'],
118 + ['Anthropic', 'anthropic.com', 'US', 'San Francisco', 37.77, -122.42, ['artificial-intelligence'], false, null, null, 2021, '1,001–5,000', 91, 'AI safety and research company building Claude.'],
119 + ['NVIDIA', 'nvidia.com', 'US', 'Santa Clara', 37.35, -121.95, ['semiconductors', 'artificial-intelligence'], true, 'NVDA', 'NASDAQ', 1993, '10,001+', 98, 'Accelerated computing and AI platforms.'],
120 + ['Apple', 'apple.com', 'US', 'Cupertino', 37.32, -122.03, ['technology', 'consumer'], true, 'AAPL', 'NASDAQ', 1976, '10,001+', 99, 'Consumer hardware, software and services.'],
121 + ['Snowflake', 'snowflake.com', 'US', 'Bozeman', 45.68, -111.04, ['cloud-infrastructure', 'software'], true, 'SNOW', 'NYSE', 2012, '5,001–10,000', 80, 'AI Data Cloud.'],
122 + ['Datadog', 'datadoghq.com', 'US', 'New York', 40.71, -74.0, ['software', 'cloud-infrastructure'], true, 'DDOG', 'NASDAQ', 2010, '5,001–10,000', 79, 'Observability and security platform.'],
123 + ['Cloudflare', 'cloudflare.com', 'US', 'San Francisco', 37.77, -122.42, ['cloud-infrastructure', 'cybersecurity'], true, 'NET', 'NYSE', 2009, '1,001–5,000', 85, 'Connectivity cloud.'],
124 + ['Revolut', 'revolut.com', 'GB', 'London', 51.5, -0.12, ['fintech', 'banking'], false, null, null, 2015, '5,001–10,000', 78, 'Global financial super-app.'],
125 + ['Monzo', 'monzo.com', 'GB', 'London', 51.5, -0.12, ['banking', 'fintech'], false, null, null, 2015, '1,001–5,000', 60, 'Digital bank.'],
126 + ['Arm', 'arm.com', 'GB', 'Cambridge', 52.2, 0.12, ['semiconductors'], true, 'ARM', 'NASDAQ', 1990, '5,001–10,000', 82, 'CPU architecture and IP.'],
127 + ['SAP', 'sap.com', 'DE', 'Walldorf', 49.3, 8.64, ['software'], true, 'SAP', 'XETRA', 1972, '10,001+', 88, 'Enterprise application software.'],
128 + ['Siemens', 'siemens.com', 'DE', 'Munich', 48.14, 11.58, ['manufacturing', 'technology'], true, 'SIE', 'XETRA', 1847, '10,001+', 90, 'Industrial technology.'],
129 + ['Zalando', 'zalando.com', 'DE', 'Berlin', 52.52, 13.4, ['e-commerce', 'retail'], true, 'ZAL', 'XETRA', 2008, '10,001+', 70, 'Online fashion platform.'],
130 + ['Mistral AI', 'mistral.ai', 'FR', 'Paris', 48.86, 2.35, ['artificial-intelligence'], false, null, null, 2023, '201–500', 76, 'Open and portable generative AI.'],
131 + ['Dassault Systèmes', '3ds.com', 'FR', 'Vélizy-Villacoublay', 48.78, 2.19, ['software', 'manufacturing'], true, 'DSY', 'Euronext', 1981, '10,001+', 77, '3D design and PLM software.'],
132 + ['Spotify', 'spotify.com', 'SE', 'Stockholm', 59.33, 18.07, ['media', 'technology'], true, 'SPOT', 'NYSE', 2006, '5,001–10,000', 83, 'Audio streaming.'],
133 + ['Klarna', 'klarna.com', 'SE', 'Stockholm', 59.33, 18.07, ['fintech'], false, null, null, 2005, '1,001–5,000', 72, 'Payments and shopping.'],
134 + ['Roche', 'roche.com', 'CH', 'Basel', 47.56, 7.59, ['pharmaceuticals', 'healthcare'], true, 'ROG', 'SIX', 1896, '10,001+', 89, 'Pharmaceuticals and diagnostics.'],
135 + ['Intercom', 'intercom.com', 'IE', 'Dublin', 53.35, -6.26, ['software'], false, null, null, 2011, '1,001–5,000', 61, 'AI-first customer service.'],
136 + ['Cabify', 'cabify.com', 'ES', 'Madrid', 40.42, -3.7, ['logistics', 'technology'], false, null, null, 2011, '1,001–5,000', 48, 'Mobility platform.'],
137 + ['Toyota', 'toyota-global.com', 'JP', 'Toyota City', 35.08, 137.16, ['automotive', 'manufacturing'], true, '7203', 'TSE', 1937, '10,001+', 95, 'Automobiles and mobility.'],
138 + ['Sony', 'sony.com', 'JP', 'Tokyo', 35.68, 139.69, ['consumer', 'media', 'technology'], true, '6758', 'TSE', 1946, '10,001+', 91, 'Electronics, entertainment and financial services.'],
139 + ['Samsung Electronics', 'samsung.com', 'KR', 'Suwon', 37.26, 127.03, ['semiconductors', 'consumer'], true, '005930', 'KRX', 1969, '10,001+', 96, 'Consumer electronics and semiconductors.'],
140 + ['Infosys', 'infosys.com', 'IN', 'Bengaluru', 12.97, 77.59, ['software', 'technology'], true, 'INFY', 'NSE', 1981, '10,001+', 80, 'Digital services and consulting.'],
141 + ['Zerodha', 'zerodha.com', 'IN', 'Bengaluru', 12.97, 77.59, ['fintech'], false, null, null, 2010, '1,001–5,000', 55, 'Discount brokerage.'],
142 + ['Grab', 'grab.com', 'SG', 'Singapore', 1.29, 103.85, ['logistics', 'fintech'], true, 'GRAB', 'NASDAQ', 2012, '5,001–10,000', 71, 'Superapp for deliveries, mobility and finance.'],
143 + ['Atlassian', 'atlassian.com', 'AU', 'Sydney', -33.87, 151.21, ['software'], true, 'TEAM', 'NASDAQ', 2002, '10,001+', 84, 'Team collaboration software.'],
144 + ['Canva', 'canva.com', 'AU', 'Sydney', -33.87, 151.21, ['software', 'media'], false, null, null, 2013, '1,001–5,000', 73, 'Visual communication platform.'],
145 + ['Nubank', 'nubank.com.br', 'BR', 'São Paulo', -23.55, -46.63, ['banking', 'fintech'], true, 'NU', 'NYSE', 2013, '5,001–10,000', 81, 'Digital banking platform.'],
146 + ['Bitso', 'bitso.com', 'MX', 'Mexico City', 19.43, -99.13, ['fintech'], false, null, null, 2014, '501–1,000', 45, 'Crypto financial services.'],
147 + ['Careem', 'careem.com', 'AE', 'Dubai', 25.2, 55.27, ['logistics', 'technology'], false, null, null, 2012, '1,001–5,000', 52, 'Everything app for the Middle East.'],
148 + ['Wiz', 'wiz.io', 'IL', 'Tel Aviv', 32.08, 34.78, ['cybersecurity', 'cloud-infrastructure'], false, null, null, 2020, '1,001–5,000', 69, 'Cloud security platform.'],
149 + ['Discovery', 'discovery.co.za', 'ZA', 'Sandton', -26.1, 28.05, ['insurance', 'financial-services'], true, 'DSY', 'JSE', 1992, '10,001+', 57, 'Shared-value insurance.'],
150 + ['Flutterwave', 'flutterwave.com', 'NG', 'Lagos', 6.52, 3.38, ['fintech'], false, null, null, 2016, '501–1,000', 50, 'Payments technology for Africa.'],
151 + ['TSMC', 'tsmc.com', 'TW', 'Hsinchu', 24.8, 120.97, ['semiconductors', 'manufacturing'], true, '2330', 'TWSE', 1987, '10,001+', 97, 'Dedicated semiconductor foundry.'],
152 + ['Ørsted', 'orsted.com', 'DE', 'Hamburg', 53.55, 9.99, ['renewable-energy', 'energy'], true, 'ORSTED', 'Nasdaq Copenhagen', 1972, '5,001–10,000', 66, 'Offshore wind developer.'],
153 + ['Nokia', 'nokia.com', 'FI', 'Espoo', 60.2, 24.66, ['telecom', 'technology'], true, 'NOKIA', 'Nasdaq Helsinki', 1865, '10,001+', 78, 'Network infrastructure and technology.'],
154 + ['Moderna', 'modernatx.com', 'US', 'Cambridge', 42.37, -71.11, ['biotech', 'pharmaceuticals'], true, 'MRNA', 'NASDAQ', 2010, '5,001–10,000', 75, 'mRNA medicines.'],
155 + ['Rocket Lab', 'rocketlabusa.com', 'US', 'Long Beach', 33.77, -118.19, ['aerospace'], true, 'RKLB', 'NASDAQ', 2006, '1,001–5,000', 64, 'Launch and space systems.'],
156 + ['Compass', 'compass.com', 'US', 'New York', 40.71, -74.0, ['real-estate', 'technology'], true, 'COMP', 'NYSE', 2012, '1,001–5,000', 47, 'Real estate technology and brokerage.'],
157 +];
158 +
159 +const SURFACES = ['homepage', 'about', 'careers', 'newsroom', 'blog', 'products', 'pricing', 'leadership', 'locations', 'investor_relations', 'documentation', 'changelog', 'legal', 'security', 'developer', 'partners', 'customers', 'sitemap', 'feed'];
160 +const SURFACE_PATH = { homepage: '/', about: '/about', careers: '/careers', newsroom: '/newsroom', blog: '/blog', products: '/products', pricing: '/pricing', leadership: '/about/leadership', locations: '/about/locations', investor_relations: '/investors', documentation: '/docs', changelog: '/changelog', legal: '/legal/terms', security: '/security', developer: '/developers', partners: '/partners', customers: '/customers', sitemap: '/sitemap.xml', feed: '/blog/rss.xml' };
161 +const CONNECTOR_FOR = { careers: 'generic_careers', newsroom: 'generic_news', blog: 'rss_connector', pricing: 'generic_pricing', leadership: 'generic_leadership', locations: 'generic_locations', products: 'generic_products', documentation: 'generic_docs', changelog: 'generic_changelog', legal: 'generic_legal', sitemap: 'sitemap_connector', feed: 'rss_connector', investor_relations: 'generic_ir' };
162 +const CONNECTORS = [
163 + ['generic_html', 'Generic HTML page', '1.4.0', 'core'],
164 + ['generic_careers', 'Generic careers page', '2.1.0', 'jobs'],
165 + ['greenhouse_connector', 'Greenhouse board', '1.2.0', 'jobs'],
166 + ['lever_connector', 'Lever board', '1.1.0', 'jobs'],
167 + ['generic_news', 'Generic newsroom', '1.3.0', 'news'],
168 + ['rss_connector', 'RSS / Atom feed', '1.0.2', 'news'],
169 + ['generic_pricing', 'Generic pricing page', '2.0.0', 'pricing'],
170 + ['generic_leadership', 'Generic leadership page', '1.1.0', 'people'],
171 + ['generic_locations', 'Generic locations page', '1.0.0', 'locations'],
172 + ['generic_products', 'Generic product catalogue', '1.2.0', 'products'],
173 + ['generic_docs', 'Documentation', '1.0.1', 'developer'],
174 + ['generic_changelog', 'Changelog', '1.0.0', 'developer'],
175 + ['generic_legal', 'Legal / terms', '1.0.0', 'legal'],
176 + ['generic_ir', 'Investor relations', '1.0.0', 'ir'],
177 + ['sitemap_connector', 'Sitemap discovery', '1.5.0', 'discovery'],
178 +];
179 +const TIERS = ['A', 'B', 'C', 'D', 'E'];
180 +const TIER_INTERVAL = { A: 900, B: 3600, C: 21600, D: 86400, E: 432000 };
181 +const FAILURE_CLASSES = ['TIMEOUT', 'HTTP_4XX', 'HTTP_5XX', 'BOT_CHALLENGE', 'PARSING', 'REDIRECT', 'PAGE_REMOVED', 'RATE_LIMIT', 'DNS'];
182 +
183 +const DEPARTMENTS = ['Engineering', 'Product', 'Sales', 'Marketing', 'Customer Success', 'Finance', 'Legal', 'Operations', 'Data', 'Design', 'Security', 'People'];
184 +const JOB_TITLES = ['Software Engineer', 'Senior Software Engineer', 'Staff Engineer', 'Machine Learning Engineer', 'Applied AI Engineer', 'Data Scientist', 'Product Manager', 'Account Executive', 'Solutions Engineer', 'Security Engineer', 'Site Reliability Engineer', 'Technical Writer', 'Designer', 'Recruiter', 'Finance Analyst', 'Legal Counsel', 'Research Scientist, LLMs', 'Developer Advocate', 'Platform Engineer', 'Support Specialist'];
185 +const PRODUCT_NAMES = ['Terminal', 'Connect', 'Radar', 'Atlas', 'Issuing', 'Billing', 'Sigma', 'Vault', 'Insights', 'Studio', 'Workflows', 'Assistant', 'Guard', 'Ledger', 'Pulse', 'Edge', 'Core API', 'Marketplace', 'Analytics', 'Identity'];
186 +const PEOPLE = ['Ava Martin', 'Noah Chen', 'Léa Dubois', 'Mateo Rossi', 'Priya Nair', 'Kenji Watanabe', 'Sofia Alvarez', 'Liam O’Connor', 'Amara Okafor', 'Hugo Lindqvist', 'Yuna Park', 'Daniel Cohen', 'Fatima Al-Sayed', 'Elena Petrova', 'Tomás Silva', 'Grace Kim', 'Arjun Mehta', 'Mia Fischer', 'Oliver Brown', 'Chloé Bernard'];
187 +const TITLES = ['Chief Executive Officer', 'Chief Financial Officer', 'Chief Technology Officer', 'Chief Operating Officer', 'Chief Product Officer', 'Chief Revenue Officer', 'General Counsel', 'Chief People Officer', 'VP Engineering', 'VP Sales', 'Head of AI', 'Chief Information Security Officer'];
188 +const CITIES = [['New York', 'US', 40.71, -74.0], ['London', 'GB', 51.5, -0.12], ['Berlin', 'DE', 52.52, 13.4], ['Paris', 'FR', 48.86, 2.35], ['Toronto', 'CA', 43.65, -79.38], ['Singapore', 'SG', 1.29, 103.85], ['Sydney', 'AU', -33.87, 151.21], ['Tokyo', 'JP', 35.68, 139.69], ['Dublin', 'IE', 53.35, -6.26], ['Bengaluru', 'IN', 12.97, 77.59], ['São Paulo', 'BR', -23.55, -46.63], ['Austin', 'US', 30.27, -97.74], ['Amsterdam', 'NL', 52.37, 4.9], ['Dubai', 'AE', 25.2, 55.27], ['Seoul', 'KR', 37.57, 126.98], ['Mexico City', 'MX', 19.43, -99.13], ['Warsaw', 'PL', 52.23, 21.01], ['Lisbon', 'PT', 38.72, -9.14]];
189 +const TREND_TERMS = ['agentic AI', 'AI engineer', 'usage-based pricing', 'data residency', 'FedRAMP', 'EU AI Act', 'sovereign cloud', 'stablecoin', 'on-device inference', 'enterprise tier', 'SOC 2', 'MCP server', 'embedded finance', 'carbon accounting', 'returns policy', 'API deprecation', 'self-serve', 'hybrid work', 'Bengaluru hub', 'German expansion'];
190 +
191 +// ------------------------------------------------------------------------------------------------ event templates
192 +// [event_type, event_subtype, importanceRange, surfaces, titleFn, summaryFn, old/new fn]
193 +const EVENT_TEMPLATES = [
194 + ['PRODUCT', 'PRODUCT_LAUNCH', [0.55, 0.92], ['products', 'homepage', 'newsroom', 'blog'], (c, p) => `${c.display_name} lists a new product: ${p.name}`, (c, p) => `A product page for ${p.name} was detected on the monitored catalog. It did not appear in the previous snapshot.`, (c, p) => [null, p.name]],
195 + ['PRODUCT', 'PRODUCT_REMOVAL', [0.5, 0.8], ['products'], (c, p) => `${p.name} no longer listed on ${c.display_name}'s product catalog`, (c, p) => `The product entry was present in the previous version of the page and is no longer visible. This does not by itself confirm discontinuation.`, (c, p) => [p.name, null]],
196 + ['PRODUCT', 'PRODUCT_RENAME', [0.35, 0.6], ['products'], (c, p) => `${c.display_name} appears to have renamed ${p.name}`, (c, p) => `The product card kept its position and description but its name changed between snapshots.`, (c, p) => [p.name, `${p.name} Pro`]],
197 + ['PRICING', 'PRICE_INCREASE', [0.6, 0.95], ['pricing'], (c, p) => `${c.display_name} ${p.plan} plan price increased`, (c, p) => `The listed monthly price of the ${p.plan} plan changed on the public pricing page.`, (c, p) => [`$${p.oldPrice}/mo`, `$${p.newPrice}/mo`]],
198 + ['PRICING', 'PRICE_DECREASE', [0.5, 0.85], ['pricing'], (c, p) => `${c.display_name} ${p.plan} plan price decreased`, (c, p) => `The listed monthly price of the ${p.plan} plan is lower than in the previous snapshot.`, (c, p) => [`$${p.newPrice}/mo`, `$${p.oldPrice}/mo`]],
199 + ['PRICING', 'NEW_PRICING_TIER', [0.55, 0.85], ['pricing'], (c, p) => `${c.display_name} adds a new pricing tier: ${p.plan}`, (c) => `A pricing column that did not exist in the previous snapshot is now listed.`, (c, p) => [null, p.plan]],
200 + ['HIRING', 'JOB_COUNT_INCREASE', [0.3, 0.75], ['careers'], (c, p) => `${c.display_name} open listings up ${p.pct} % over 7 days`, (c, p) => `${p.n} listings were added on the monitored careers surface, ${p.ai} of them mentioning AI or machine learning.`, (c, p) => [`${p.from} open listings`, `${p.to} open listings`]],
201 + ['HIRING', 'JOB_COUNT_DECREASE', [0.4, 0.85], ['careers'], (c, p) => `${p.n} ${c.display_name} listings are no longer visible`, (c, p) => `${p.n} monitored job listings are no longer visible on the careers page (${p.pct} % of the previous count). This reflects public listings only and is not evidence of layoffs.`, (c, p) => [`${p.from} open listings`, `${p.to} open listings`]],
202 + ['LEADERSHIP', 'NEW_EXECUTIVE', [0.6, 0.9], ['leadership', 'newsroom'], (c, p) => `${p.name} listed as ${p.title} at ${c.display_name}`, (c, p) => `A new profile appeared on the monitored leadership page.`, (c, p) => [null, `${p.name} — ${p.title}`]],
203 + ['LEADERSHIP', 'EXECUTIVE_REMOVED', [0.6, 0.9], ['leadership'], (c, p) => `${p.name} no longer listed on ${c.display_name}'s leadership page`, (c, p) => `The profile for ${p.name} (${p.title}) is no longer visible on the monitored leadership page. The reason is not stated on the page.`, (c, p) => [`${p.name} — ${p.title}`, null]],
204 + ['LOCATION', 'NEW_OFFICE', [0.5, 0.8], ['locations', 'careers'], (c, p) => `${c.display_name} lists a new office in ${p.city}`, (c, p) => `A ${p.city}, ${p.country} location was added to the monitored locations page.`, (c, p) => [null, `${p.city}, ${p.country}`]],
205 + ['LOCATION', 'COUNTRY_EXPANSION', [0.65, 0.92], ['locations', 'careers', 'newsroom'], (c, p) => `${c.display_name} appears to expand into ${p.country}`, (c, p) => `First monitored presence in ${p.country}: a location entry and ${p.jobs} job listings referencing ${p.city}.`, (c, p) => [null, p.country]],
206 + ['LOCATION', 'OFFICE_REMOVED', [0.45, 0.75], ['locations'], (c, p) => `${c.display_name}'s ${p.city} office no longer listed`, (c, p) => `The ${p.city} entry was present in the previous snapshot of the locations page and is no longer visible.`, (c, p) => [`${p.city}, ${p.country}`, null]],
207 + ['DEVELOPER', 'API_LAUNCH', [0.55, 0.85], ['developer', 'documentation', 'changelog'], (c, p) => `${c.display_name} publishes a new API: ${p.name} API`, (c, p) => `A new reference section for the ${p.name} API appeared in the developer documentation.`, (c, p) => [null, `${p.name} API v1`]],
208 + ['DEVELOPER', 'DOCUMENTATION_CHANGE', [0.25, 0.55], ['documentation', 'changelog'], (c, p) => `${c.display_name} documentation updated: ${p.section}`, (c, p) => `${p.blocks} blocks changed in the ${p.section} section of the monitored documentation.`, () => [null, null]],
209 + ['LEGAL', 'TERMS_CHANGE', [0.45, 0.8], ['legal'], (c, p) => `${c.display_name} terms of service updated (section ${p.section})`, (c, p) => `Section ${p.section} of the terms was materially updated; ${p.pct} % of the text changed. The previous version is preserved.`, (c, p) => [`Terms v${p.v}`, `Terms v${p.v + 1}`]],
210 + ['COMMUNICATION', 'NEWS_RELEASE', [0.3, 0.7], ['newsroom', 'blog', 'feed'], (c, p) => `${c.display_name}: ${p.headline}`, (c, p) => `A first-party newsroom item was published.`, () => [null, null]],
211 + ['PARTNERSHIP', 'NEW_PARTNERSHIP', [0.5, 0.8], ['newsroom', 'partners'], (c, p) => `${c.display_name} announces a partnership with ${p.partner}`, (c, p) => `A partnership was described in a first-party release and ${p.partner} now appears on the partners page.`, (c, p) => [null, p.partner]],
212 + ['M&A', 'ACQUISITION', [0.75, 0.98], ['newsroom', 'investor_relations'], (c, p) => `${c.display_name} announces the acquisition of ${p.target}`, (c, p) => `Stated in a first-party release on the newsroom; corroborated by an investor relations notice ${p.delay} minutes later.`, (c, p) => [null, p.target]],
213 + ['FINANCING', 'FUNDING_ROUND', [0.7, 0.95], ['newsroom', 'blog'], (c, p) => `${c.display_name} states a ${p.amount} funding round`, (c, p) => `Amount as stated by the company in its own release; not independently verified.`, (c, p) => [null, p.amount]],
214 + ['SECURITY', 'SECURITY_NOTICE', [0.6, 0.9], ['security', 'blog'], (c, p) => `${c.display_name} publishes a security notice`, (c, p) => `A new advisory appeared on the monitored security page (${p.kind}).`, () => [null, null]],
215 + ['STRATEGY', 'BRAND_REPOSITIONING', [0.45, 0.75], ['homepage', 'about'], (c, p) => `${c.display_name} homepage headline changed`, (c, p) => `The hero headline changed from an SMB-oriented message to enterprise language. Signal, not a confirmed strategy change.`, (c, p) => [p.oldHeadline, p.newHeadline]],
216 + ['TECHNOLOGY', 'TECHNOLOGY_SIGNAL', [0.3, 0.6], ['developer', 'blog', 'careers'], (c, p) => `${c.display_name} references ${p.tech} across ${p.n} surfaces`, (c, p) => `${p.tech} appeared in new documentation and ${p.jobs} job listings within the same week.`, () => [null, null]],
217 + ['INVESTOR_RELATIONS', 'IR_UPDATE', [0.4, 0.7], ['investor_relations'], (c, p) => `${c.display_name} posts ${p.item} on investor relations`, () => `Published on the company's investor relations page; not a substitute for regulated filings.`, () => [null, null]],
218 + ['SUSTAINABILITY', 'SUSTAINABILITY_UPDATE', [0.3, 0.6], ['about', 'newsroom'], (c, p) => `${c.display_name} publishes its ${p.year} sustainability report`, () => `A new report link appeared on the monitored page.`, () => [null, null]],
219 + ['MARKETING', 'CAMPAIGN_CHANGE', [0.2, 0.45], ['homepage'], (c) => `${c.display_name} homepage hero campaign changed`, () => `Hero imagery and call-to-action blocks were replaced; classified as marketing noise above the significance floor.`, () => [null, null]],
220 + ['OPERATIONS', 'STATUS_INCIDENT', [0.4, 0.7], ['status', 'security'], (c, p) => `${c.display_name} status page reports ${p.kind}`, () => `Observed on the public status page.`, () => [null, null]],
221 +];
222 +const HEADLINES = ['expands enterprise offering to the EU', 'reports record quarter for developer sign-ups', 'opens applications for its startup program', 'introduces new sustainability commitments', 'launches regional data residency', 'names new advisory board members', 'publishes annual developer survey', 'partners with universities on AI research'];
223 +const PARTNERS = ['Microsoft', 'AWS', 'Google Cloud', 'Accenture', 'Deloitte', 'Salesforce', 'Visa', 'Mastercard', 'Snowflake', 'NVIDIA'];
224 +const TECHS = ['Rust', 'Kubernetes', 'MCP', 'vector search', 'WebAssembly', 'PostgreSQL', 'Kafka', 'Terraform', 'Claude', 'LLM evaluation'];
225 +
226 +// ------------------------------------------------------------------------------------------------ build dataset
227 +const companies = [];
228 +const companiesBySlug = new Map();
229 +const sensors = new Map();
230 +const snapshots = new Map();
231 +const changes = new Map();
232 +const events = [];
233 +const eventsById = new Map();
234 +const perCompany = new Map();
235 +
236 +function series(days, base, vol, drift = 0) {
237 + const out = [];
238 + let v = base;
239 + for (let i = days - 1; i >= 0; i--) {
240 + v = clamp(v + (rnd() - 0.5) * vol + drift, 0, 100);
241 + out.push({ day: day(NOW - i * DAY), value: r1(v), confidence: r1(0.6 + rnd() * 0.35) });
242 + }
243 + return out;
244 +}
245 +
246 +const blockKinds = ['heading', 'paragraph', 'list', 'card', 'table', 'nav'];
247 +function makeBlocks(surface, n) {
248 + const blocks = [];
249 + for (let i = 0; i < n; i++) {
250 + const kind = pick(blockKinds);
251 + blocks.push({ key: `${surface}:${kind}:${i}`, kind, path: `main > section:nth-of-type(${1 + Math.floor(i / 3)}) > ${kind === 'heading' ? 'h2' : kind === 'list' ? 'ul' : 'div'}:nth-child(${1 + (i % 3)})`, text: sampleText(surface, i) });
252 + }
253 + return blocks;
254 +}
255 +function sampleText(surface, i) {
256 + const map = {
257 + pricing: ['Starter — $29 per month. Up to 3 seats, community support.', 'Growth — $99 per month. Unlimited seats, SSO, priority support.', 'Enterprise — Contact sales. Custom SLAs, dedicated support, audit logs.', 'All plans include 14-day trial. Prices in USD, billed annually.'],
258 + careers: ['Senior Software Engineer — Toronto, Canada · Engineering', 'Machine Learning Engineer — Remote (EU) · AI Platform', 'Account Executive — London, UK · Sales', 'We are hiring across 12 offices. See all 148 open roles.'],
259 + leadership: ['Ava Martin — Chief Executive Officer', 'Noah Chen — Chief Technology Officer', 'Léa Dubois — Chief Financial Officer', 'Our leadership team brings decades of experience across payments and infrastructure.'],
260 + locations: ['Headquarters — 354 Oyster Point Blvd, South San Francisco', 'Dublin — Grand Canal Dock', 'Singapore — Raffles Place', 'Bengaluru — Indiranagar'],
261 + legal: ['7. Limitation of liability. To the maximum extent permitted by law…', '8. Governing law. These terms are governed by the laws of Ireland.', '12. Changes to these terms. We may update these terms; material changes will be notified 30 days in advance.'],
262 + products: ['Terminal — In-person payments hardware and SDKs.', 'Radar — Fraud prevention powered by network data.', 'Atlas — Company incorporation in days.', 'Billing — Subscriptions, invoices and revenue recovery.'],
263 + };
264 + const arr = map[surface] ?? ['Welcome to our company. We build tools that help teams move faster.', 'Trusted by thousands of businesses worldwide.', 'Read our latest news and product updates.', 'Contact us to learn more about enterprise plans.'];
265 + return arr[i % arr.length];
266 +}
267 +
268 +function buildCompany(row, idx) {
269 + const [name, domain, country, city, lat, lon, inds, pub, ticker, exchange, founded, band, importance, desc] = row;
270 + const slug = slugify(name);
271 + const cid = id('co');
272 + const tier = importance >= 90 ? 1 : importance >= 75 ? 2 : importance >= 55 ? 3 : 4;
273 + const created = NOW - ri(120, 420) * DAY;
274 + // sensors
275 + const nSensors = tier === 1 ? ri(28, 63) : tier === 2 ? ri(16, 32) : tier === 3 ? ri(8, 18) : ri(4, 10);
276 + const compSensors = [];
277 + const surfacesUsed = [];
278 + for (let i = 0; i < nSensors; i++) {
279 + const surface = i < SURFACES.length ? SURFACES[i] : pick(SURFACES);
280 + surfacesUsed.push(surface);
281 + const sid = id('sen');
282 + const stier = surface === 'homepage' || surface === 'newsroom' || surface === 'careers' ? pick(['A', 'B']) : surface === 'legal' || surface === 'sitemap' ? pick(['D', 'E']) : pick(['B', 'C', 'C', 'D']);
283 + const statusRoll = rnd();
284 + const status = statusRoll < 0.86 ? 'active' : statusRoll < 0.93 ? 'failing' : statusRoll < 0.97 ? 'paused' : 'retired';
285 + const obs = ri(40, 2400);
286 + const snaps = ri(3, 12);
287 + const chg = Math.floor(snaps * (0.4 + rnd()));
288 + const path = SURFACE_PATH[surface] ?? `/${surface}`;
289 + const url = `https://${domain}${path}${i >= SURFACES.length ? `?p=${i}` : ''}`;
290 + const lastRun = NOW - ri(1, 300) * 60_000;
291 + const failing = status === 'failing';
292 + const sensor = {
293 + id: sid,
294 + company_id: cid,
295 + surface,
296 + connector_id: CONNECTOR_FOR[surface] ?? 'generic_html',
297 + url,
298 + canonical_url: url,
299 + domain,
300 + status,
301 + tier: stier,
302 + quality_score: r1(45 + rnd() * 55),
303 + discovery_confidence: r1(0.55 + rnd() * 0.45),
304 + discovery_method: pick(['navigation', 'sitemap', 'url_pattern', 'json_ld', 'manual']),
305 + current_interval_s: TIER_INTERVAL[stier] * (failing ? 4 : 1),
306 + next_run_at: iso(lastRun + TIER_INTERVAL[stier] * 1000),
307 + last_run_at: iso(lastRun),
308 + last_success_at: failing ? iso(NOW - ri(2, 6) * DAY) : iso(lastRun),
309 + last_change_at: iso(NOW - ri(1, 30) * DAY),
310 + last_status: failing ? pick([403, 429, 503, 0]) : 200,
311 + last_failure_class: failing ? pick(FAILURE_CLASSES) : null,
312 + consecutive_failures: failing ? ri(3, 40) : 0,
313 + observation_count: obs,
314 + snapshot_count: snaps,
315 + change_count: chg,
316 + meaningful_change_count: Math.floor(chg * 0.35),
317 + event_count: 0,
318 + created_at: iso(created + ri(0, 10) * DAY),
319 + _versions: [],
320 + _changes: [],
321 + };
322 + // snapshots (versions) and changes
323 + let prev = null;
324 + let t = created + ri(1, 20) * DAY;
325 + for (let v = 1; v <= snaps; v++) {
326 + const snapId = id('snap');
327 + t += ri(2, 20) * DAY;
328 + if (t > NOW) t = NOW - ri(0, 3) * 3600_000;
329 + const blocks = makeBlocks(surface, ri(8, 20));
330 + const snap = {
331 + id: snapId,
332 + sensor_id: sid,
333 + version_no: v,
334 + fetched_at: iso(t),
335 + title: `${name} — ${surface.replace('_', ' ')}`,
336 + language: pick(['en', 'en', 'en', 'fr', 'de', 'ja']),
337 + text_length: blocks.reduce((a, b) => a + b.text.length, 0) * ri(8, 20),
338 + block_count: blocks.length,
339 + extracted_summary: surface === 'careers' ? { jobs: ri(5, 200) } : surface === 'pricing' ? { plans: ri(2, 5) } : surface === 'leadership' ? { people: ri(4, 14) } : surface === 'locations' ? { locations: ri(1, 12) } : { blocks: blocks.length },
340 + content_hash: `sha256:${Array.from({ length: 16 }, () => B32[Math.floor(rnd() * 32)].toLowerCase()).join('')}`,
341 + previous_snapshot_id: prev ? prev.id : null,
342 + _blocks: blocks,
343 + _text: blocks.map((b) => b.text).join('\n\n'),
344 + };
345 + snapshots.set(snapId, snap);
346 + sensor._versions.push(snap);
347 + if (prev) {
348 + const chgId = id('chg');
349 + const added = blocks.slice(0, ri(0, 3)).map((b) => ({ key: b.key, kind: b.kind, path: b.path, before: null, after: b.text, weight: r1(0.3 + rnd() * 0.7), similarity: null }));
350 + const removed = prev._blocks.slice(-ri(0, 2)).map((b) => ({ key: b.key, kind: b.kind, path: b.path, before: b.text, after: null, weight: r1(0.3 + rnd() * 0.7), similarity: null }));
351 + const modified = prev._blocks.slice(1, 1 + ri(0, 3)).map((b, k) => ({ key: b.key, kind: b.kind, path: b.path, before: b.text, after: (blocks[k + 1] ?? blocks[0]).text, weight: r1(0.3 + rnd() * 0.7), similarity: r1(0.4 + rnd() * 0.5) }));
352 + const sig = clamp(r1(0.1 + (added.length + removed.length + modified.length) * 0.12 + rnd() * 0.25), 0.02, 0.98);
353 + const reasons = [];
354 + if (added.length) reasons.push(`${added.length} new block${added.length > 1 ? 's' : ''} (${added.map((a) => a.kind).join(', ')})`);
355 + if (removed.length) reasons.push(`${removed.length} block${removed.length > 1 ? 's' : ''} no longer present`);
356 + if (modified.length) reasons.push(`${modified.length} block${modified.length > 1 ? 's' : ''} modified with low similarity`);
357 + if (surface === 'pricing') reasons.push('structured pricing fields changed');
358 + if (surface === 'careers') reasons.push('job count changed');
359 + if (!reasons.length) reasons.push('text delta below noise threshold');
360 + const change = {
361 + id: chgId,
362 + sensor_id: sid,
363 + surface,
364 + company_id: cid,
365 + detected_at: snap.fetched_at,
366 + significance: sig,
367 + kind: sig < 0.2 ? 'noise' : sig < 0.4 ? 'minor' : sig < 0.65 ? 'meaningful' : sig < 0.85 ? 'major' : 'critical',
368 + blocks_added: added.length,
369 + blocks_removed: removed.length,
370 + blocks_modified: modified.length,
371 + text_delta_ratio: r1(sig * 0.6),
372 + similarity: r1(1 - sig * 0.7),
373 + snapshot_before: prev.id,
374 + snapshot_after: snapId,
375 + diff: { added, removed, modified, moved: [], counts: { added: added.length, removed: removed.length, modified: modified.length, unchanged: Math.max(0, blocks.length - added.length - modified.length) }, text_delta_ratio: r1(sig * 0.6), similarity: r1(1 - sig * 0.7), reasons },
376 + structured_delta: surface === 'careers' ? { jobs_added: added.length + ri(0, 4), jobs_removed: removed.length } : surface === 'pricing' ? { plans_changed: modified.length } : {},
377 + _events: [],
378 + };
379 + changes.set(chgId, change);
380 + sensor._changes.push(change);
381 + }
382 + prev = snap;
383 + }
384 + sensors.set(sid, sensor);
385 + compSensors.push(sensor);
386 + }
387 +
388 + // entities
389 + const jobs = [];
390 + const nJobs = tier === 1 ? ri(60, 240) : tier === 2 ? ri(20, 90) : ri(3, 30);
391 + for (let i = 0; i < nJobs; i++) {
392 + const title = pick(JOB_TITLES);
393 + const cityRow = chance(0.5) ? [city, country] : pick(CITIES);
394 + const first = NOW - ri(1, 120) * DAY;
395 + const removed = chance(0.22);
396 + const removedAt = removed ? first + ri(5, 60) * DAY : null;
397 + jobs.push({
398 + id: id('job'),
399 + title,
400 + department: pick(DEPARTMENTS),
401 + location_text: `${cityRow[0]}, ${cityRow[1]}`,
402 + city: cityRow[0],
403 + country: cityRow[1],
404 + remote: chance(0.35),
405 + employment_type: pick(['full_time', 'full_time', 'contract', 'internship']),
406 + seniority: pick(['junior', 'mid', 'senior', 'staff', 'lead']),
407 + url: `https://${domain}/careers/${slugify(title)}-${i}`,
408 + posted_at: iso(first - ri(0, 3) * DAY),
409 + first_seen_at: iso(first),
410 + last_seen_at: iso(removed ? Math.min(NOW, removedAt) : NOW - ri(0, 3) * 3600_000),
411 + removed_at: removed && removedAt < NOW ? iso(removedAt) : null,
412 + status: removed && removedAt < NOW ? 'no_longer_listed' : 'open',
413 + is_ai: /AI|Machine Learning|LLM|Research/.test(title) || chance(0.1),
414 + });
415 + }
416 + const people = [];
417 + for (let i = 0; i < ri(5, 12); i++) {
418 + const removed = chance(0.2);
419 + const first = NOW - ri(30, 400) * DAY;
420 + people.push({ id: id('per'), name: PEOPLE[(idx * 3 + i) % PEOPLE.length], title: TITLES[i % TITLES.length], role_category: i < 6 ? 'c_suite' : 'vp', is_executive: i < 8, first_seen_at: iso(first), last_seen_at: iso(removed ? first + ri(10, 200) * DAY : NOW - ri(0, 2) * DAY), removed_at: removed ? iso(first + ri(10, 200) * DAY) : null, status: removed ? 'no_longer_listed' : 'listed', source_url: `https://${domain}/about/leadership` });
421 + }
422 + const products = [];
423 + for (let i = 0; i < ri(3, 10); i++) {
424 + const removed = chance(0.15);
425 + const first = NOW - ri(30, 400) * DAY;
426 + const pname = PRODUCT_NAMES[(idx * 5 + i) % PRODUCT_NAMES.length];
427 + products.push({ id: id('prd'), name: pname, category: pick(['platform', 'api', 'app', 'hardware', 'service']), description: `${pname} by ${name}.`, url: `https://${domain}/products/${slugify(pname)}`, first_seen_at: iso(first), last_seen_at: iso(removed ? first + ri(10, 200) * DAY : NOW - ri(0, 2) * DAY), removed_at: removed ? iso(first + ri(10, 200) * DAY) : null, status: removed ? 'removed' : 'listed' });
428 + }
429 + const plans = [];
430 + const planNames = ['Starter', 'Growth', 'Scale', 'Enterprise'];
431 + let pv = 1;
432 + for (let i = 0; i < ri(2, 4); i++) {
433 + const pname = planNames[i];
434 + const enterprise = pname === 'Enterprise';
435 + let price = enterprise ? null : [29, 99, 299][i] ?? 49;
436 + const versions = ri(1, 3);
437 + let from = NOW - ri(200, 400) * DAY;
438 + for (let v = 1; v <= versions; v++) {
439 + const last = v === versions;
440 + const to = last ? null : from + ri(40, 120) * DAY;
441 + plans.push({ id: id('pln'), plan_name: pname, price, price_text: enterprise ? 'Contact sales' : `$${price}/mo`, currency: enterprise ? null : 'USD', billing_period: enterprise ? null : 'month', unit: enterprise ? null : 'seat', features: enterprise ? ['Custom SLAs', 'Dedicated support', 'Audit logs', 'SSO'] : [`${[3, 10, 50][i] ?? 5} seats`, 'API access', v > 1 ? 'Priority support' : 'Community support'], contact_sales: enterprise, version_no: pv++, valid_from: iso(from), valid_to: to ? iso(to) : null, status: last ? 'current' : 'superseded', source_url: `https://${domain}/pricing` });
442 + if (to) from = to;
443 + if (price !== null) price = Math.round(price * (1 + (rnd() * 0.3 - 0.05)));
444 + }
445 + }
446 + const locations = [{ id: id('loc'), kind: 'headquarters', name: `${name} HQ`, city, region: null, country, lat, lon, first_seen_at: iso(created), last_seen_at: iso(NOW - DAY), removed_at: null, status: 'listed', source_url: `https://${domain}/about/locations` }];
447 + for (let i = 0; i < (tier <= 2 ? ri(3, 9) : ri(0, 3)); i++) {
448 + const c = pick(CITIES);
449 + const removed = chance(0.15);
450 + const first = NOW - ri(30, 400) * DAY;
451 + locations.push({ id: id('loc'), kind: pick(['office', 'office', 'office', 'lab', 'warehouse', 'store']), name: `${c[0]} office`, city: c[0], region: null, country: c[1], lat: c[2], lon: c[3], first_seen_at: iso(first), last_seen_at: iso(removed ? first + ri(10, 200) * DAY : NOW - DAY), removed_at: removed ? iso(first + ri(10, 200) * DAY) : null, status: removed ? 'no_longer_listed' : 'listed', source_url: `https://${domain}/about/locations` });
452 + }
453 + const news = [];
454 + for (let i = 0; i < ri(5, 18); i++) {
455 + const t = NOW - ri(0, 120) * DAY;
456 + news.push({ id: id('nws'), title: `${name} ${pick(HEADLINES)}`, url: `https://${domain}/newsroom/${t}`, summary: 'First-party newsroom item.', category: pick(['press', 'product', 'company', 'research']), published_at: iso(t), first_seen_at: iso(t + ri(5, 240) * 60_000), language: 'en' });
457 + }
458 +
459 + // metrics
460 + const activity = clamp(importance * 0.6 + rnd() * 40 - 10, 5, 99);
461 + const hiring30 = r1((rnd() - 0.42) * 60);
462 + const metrics = {
463 + activity_score: r1(activity),
464 + hiring_momentum_7d: r1(hiring30 / 3 + (rnd() - 0.5) * 8),
465 + hiring_momentum_30d: hiring30,
466 + hiring_momentum_90d: r1(hiring30 * 1.6 + (rnd() - 0.5) * 12),
467 + open_jobs: jobs.filter((j) => j.status === 'open').length,
468 + ai_adoption: r1(clamp(inds.includes('artificial-intelligence') ? 70 + rnd() * 30 : rnd() * 70, 0, 100)),
469 + product_velocity: r1(clamp(rnd() * 90, 0, 100)),
470 + geo_expansion: r1(clamp(rnd() * 80, 0, 100)),
471 + developer_momentum: r1(clamp((inds.includes('software') || inds.includes('cloud-infrastructure') ? 40 : 5) + rnd() * 60, 0, 100)),
472 + communication_activity: r1(rnd() * 100),
473 + pricing_activity: r1(rnd() * 60),
474 + leadership_activity: r1(rnd() * 50),
475 + corporate_change_index: 0,
476 + anomaly_score: r1(rnd() * 100),
477 + historical_coverage: r1(60 + rnd() * 40),
478 + };
479 + metrics.corporate_change_index = r1(0.25 * clamp(50 + hiring30, 0, 100) + 0.2 * metrics.product_velocity + 0.15 * metrics.geo_expansion + 0.15 * metrics.leadership_activity + 0.1 * metrics.developer_momentum + 0.1 * metrics.communication_activity + 0.05 * metrics.pricing_activity);
480 + if (chance(0.12)) delete metrics.ai_adoption; // omit when no inputs (never fabricate)
481 + if (chance(0.1)) delete metrics.developer_momentum;
482 + const act30 = series(30, metrics.activity_score, 12);
483 + const hir90 = series(90, 50 + hiring30 / 2, 8);
484 + const seriesAll = { activity_score: series(90, metrics.activity_score, 10), hiring_momentum_30d: hir90.map((p) => ({ ...p, value: r1((p.value - 50) * 2) })), product_velocity: series(90, metrics.product_velocity, 9), ai_adoption: series(90, metrics.ai_adoption ?? 30, 6, 0.1), corporate_change_index: series(90, metrics.corporate_change_index, 7), open_jobs: series(90, 50, 4).map((p) => ({ ...p, value: Math.round(metrics.open_jobs * (0.7 + p.value / 160)) })) };
485 + seriesAll.activity_score.splice(-30, 30, ...act30);
486 +
487 + const company = {
488 + id: cid,
489 + slug,
490 + display_name: name,
491 + legal_name: `${name}${pub ? ' Inc.' : ', Inc.'}`,
492 + canonical_domain: domain,
493 + website: `https://${domain}`,
494 + description: desc,
495 + industries: inds,
496 + industry_primary: inds[0],
497 + country,
498 + hq_city: city,
499 + hq_region: null,
500 + public_company: pub,
501 + ticker,
502 + exchange,
503 + founded_year: founded,
504 + employees_band: band,
505 + logo_url: null,
506 + status: chance(0.94) ? 'ACTIVE' : 'POSSIBLY_INACTIVE',
507 + onboarding_status: 'active',
508 + importance,
509 + tier,
510 + metrics,
511 + counts: { sensors: compSensors.filter((s) => s.status === 'active').length, observations: compSensors.reduce((a, s) => a + s.observation_count, 0), changes: compSensors.reduce((a, s) => a + s.change_count, 0), events: 0, jobs_open: metrics.open_jobs },
512 + last_event_at: null,
513 + last_observed_at: iso(NOW - ri(1, 90) * 60_000),
514 + sparkline: act30.map((p) => p.value),
515 + _lat: lat,
516 + _lon: lon,
517 + _created: created,
518 + _sensors: compSensors,
519 + _jobs: jobs,
520 + _people: people,
521 + _products: products,
522 + _plans: plans,
523 + _locations: locations,
524 + _news: news,
525 + _series: seriesAll,
526 + _hir90: hir90.map((p) => r1((p.value - 50) * 2)),
527 + _signals: [],
528 + _aliases: [name.toUpperCase(), `${name} Inc`, domain.split('.')[0]],
529 + };
530 + companies.push(company);
531 + companiesBySlug.set(slug, company);
532 + companiesBySlug.set(cid, company);
533 + return company;
534 +}
535 +
536 +SEED.forEach((row, i) => buildCompany(row, i));
537 +
538 +// events
539 +function makeEvent(c, template, t, opts = {}) {
540 + const [type, subtype, [imLo, imHi], surfaces, titleFn, summaryFn, ovFn] = template;
541 + const p = {
542 + name: pick(PRODUCT_NAMES),
543 + plan: pick(['Starter', 'Growth', 'Scale', 'Team']),
544 + oldPrice: ri(19, 199),
545 + city: pick(CITIES)[0],
546 + country: COUNTRY_META[pick(Object.keys(COUNTRY_META))][0],
547 + jobs: ri(2, 14),
548 + n: ri(3, 72),
549 + pct: ri(5, 45),
550 + ai: ri(0, 9),
551 + from: ri(40, 300),
552 + section: pick(['7', '8.2', '12', '3.1', 'Data processing addendum', 'Authentication', 'Webhooks', 'Rate limits']),
553 + blocks: ri(2, 30),
554 + v: ri(3, 14),
555 + headline: pick(HEADLINES),
556 + partner: pick(PARTNERS),
557 + target: `${pick(['Nimbus', 'Lattice', 'Parcel', 'Quill', 'Beacon'])} ${pick(['Labs', 'Systems', 'AI', 'Technologies'])}`,
558 + delay: ri(3, 90),
559 + amount: `$${pick([40, 75, 120, 250, 500])}M`,
560 + kind: pick(['advisory', 'partial outage', 'degraded performance', 'disclosure']),
561 + oldHeadline: 'Payments for small businesses',
562 + newHeadline: 'The financial infrastructure platform for enterprises',
563 + tech: pick(TECHS),
564 + item: pick(['Q3 results', 'annual report', 'investor day materials', 'a shareholder letter']),
565 + year: 2025,
566 + name2: pick(PEOPLE),
567 + title: pick(TITLES),
568 + };
569 + p.newPrice = subtype === 'PRICE_INCREASE' ? Math.round(p.oldPrice * (1.05 + rnd() * 0.3)) : Math.round(p.oldPrice * (0.7 + rnd() * 0.25));
570 + p.to = subtype === 'JOB_COUNT_INCREASE' ? p.from + p.n : Math.max(0, p.from - p.n);
571 + if (type === 'LEADERSHIP') {
572 + const person = pick(c._people);
573 + p.name = person.name;
574 + p.title = person.title;
575 + }
576 + const [oldV, newV] = ovFn(c, p);
577 + const surface = pick(surfaces);
578 + const sensor = c._sensors.find((s) => s.surface === surface) ?? c._sensors[0];
579 + const change = sensor._changes.length ? pick(sensor._changes) : null;
580 + const origin = pick(['deterministic', 'deterministic', 'deterministic', 'llm', 'hybrid', 'backfill']);
581 + const confidence = r1(clamp(0.45 + rnd() * 0.55 - (origin === 'llm' ? 0.12 : 0), 0.3, 0.99));
582 + const label = confidence >= 0.95 ? 'VERIFIED' : confidence >= 0.85 ? 'HIGH_CONFIDENCE' : confidence >= 0.7 ? 'LIKELY' : confidence >= 0.55 ? 'INFERRED' : 'LOW_CONFIDENCE';
583 + const importance = r1(imLo + rnd() * (imHi - imLo));
584 + const statusRoll = rnd();
585 + const eid = id('evt');
586 + const ev = {
587 + id: eid,
588 + company: { id: c.id, slug: c.slug, display_name: c.display_name, canonical_domain: c.canonical_domain, country: c.country, logo_url: null },
589 + event_type: type,
590 + event_subtype: subtype,
591 + importance,
592 + confidence,
593 + confidence_label: label,
594 + title: titleFn(c, p),
595 + summary: summaryFn(c, p),
596 + old_value: oldV,
597 + new_value: newV,
598 + payload: { template: subtype, ...(type === 'HIRING' ? { count_before: p.from, count_after: p.to } : {}), ...(type === 'PRICING' ? { plan: p.plan, price_before: p.oldPrice, price_after: p.newPrice, currency: 'USD' } : {}) },
599 + entities: type === 'LEADERSHIP' ? { person: p.name, title: p.title } : type === 'LOCATION' ? { city: p.city, country: p.country } : type === 'PRODUCT' ? { product: p.name } : {},
600 + tags: [...new Set([type.toLowerCase(), surface, ...(p.ai > 4 && type === 'HIRING' ? ['ai'] : [])])],
601 + detected_at: iso(t),
602 + effective_at: chance(0.5) ? iso(t - ri(0, 3) * DAY) : null,
603 + published_at: type === 'COMMUNICATION' || type === 'M&A' ? iso(t - ri(5, 240) * 60_000) : null,
604 + source_url: sensor.url,
605 + surface,
606 + sensor_id: sensor.id,
607 + change_id: change ? change.id : null,
608 + cluster_id: chance(0.3) ? id('cls') : null,
609 + origin,
610 + model_name: origin === 'llm' || origin === 'hybrid' ? 'qwen3.6-35b-a3b-4bit' : null,
611 + prompt_version: origin === 'llm' || origin === 'hybrid' ? 'event-classifier/v3' : null,
612 + status: opts.live ? 'active' : statusRoll < 0.94 ? 'active' : statusRoll < 0.97 ? 'retracted' : statusRoll < 0.99 ? 'review' : 'duplicate',
613 + sources: [{ source_url: sensor.url, surface, detected_at: iso(t), kind: 'primary', sensor_id: sensor.id }],
614 + };
615 + if (chance(0.4)) {
616 + const other = pick(c._sensors);
617 + ev.sources.push({ source_url: other.url, surface: other.surface, detected_at: iso(t + ri(2, 180) * 60_000), kind: 'corroboration', sensor_id: other.id });
618 + }
619 + if (change) change._events.push(ev.id);
620 + sensor.event_count += 1;
621 + return ev;
622 +}
623 +
624 +for (const c of companies) {
625 + const n = c.tier === 1 ? ri(90, 260) : c.tier === 2 ? ri(40, 120) : c.tier === 3 ? ri(12, 50) : ri(3, 15);
626 + for (let i = 0; i < n; i++) {
627 + const t = NOW - Math.floor(Math.pow(rnd(), 1.6) * 365 * DAY) - ri(0, 3600_000);
628 + events.push(makeEvent(c, pick(EVENT_TEMPLATES), t));
629 + }
630 +}
631 +// make sure the most recent hours are populated for the live feed
632 +for (let i = 0; i < 60; i++) {
633 + const c = pick(companies);
634 + events.push(makeEvent(c, pick(EVENT_TEMPLATES), NOW - ri(1, 360) * 60_000));
635 +}
636 +events.sort((a, b) => (a.detected_at < b.detected_at ? 1 : -1));
637 +for (const e of events) {
638 + eventsById.set(e.id, e);
639 + const c = companiesBySlug.get(e.company.slug);
640 + if (!perCompany.has(c.slug)) perCompany.set(c.slug, []);
641 + perCompany.get(c.slug).push(e);
642 +}
643 +for (const c of companies) {
644 + const list = perCompany.get(c.slug) ?? [];
645 + c.counts.events = list.filter((e) => e.status === 'active').length;
646 + c.last_event_at = list[0]?.detected_at ?? null;
647 +}
648 +
649 +// signals
650 +const SIGNAL_KINDS = [['hiring_surge', 'Hiring surge signal'], ['hiring_freeze', 'Hiring slowdown signal'], ['launch_buildup', 'Possible launch preparation signal'], ['international_expansion', 'International expansion signal'], ['pricing_migration', 'Pricing migration signal'], ['developer_push', 'Developer ecosystem push'], ['enterprise_repositioning', 'Enterprise repositioning signal'], ['ai_acceleration', 'AI acceleration signal']];
651 +const signals = [];
652 +for (const c of companies) {
653 + for (let i = 0; i < ri(0, 3); i++) {
654 + const [kind, label] = pick(SIGNAL_KINDS);
655 + const s = { id: id('sig'), company_id: c.id, scope: 'company', scope_key: c.slug, kind, strength: r1(0.3 + rnd() * 0.7), confidence: r1(0.4 + rnd() * 0.5), title: `${label} — ${c.display_name}`, explanation: kind === 'launch_buildup' ? 'Documentation, careers and changelog surfaces changed together within 9 days; historically this pattern preceded a product listing in 4 of 7 monitored cases for this company. Presented as a signal, not a fact.' : kind === 'hiring_surge' ? `Open listings are ${ri(20, 80)} % above the 90-day baseline, concentrated in ${pick(DEPARTMENTS)}.` : 'Derived from per-company baselines of monitored surfaces.', evidence: { events: ri(2, 12), surfaces: ri(2, 5), window_days: 30 }, window_days: 30, detected_at: iso(NOW - ri(1, 20) * DAY), status: 'active' };
656 + signals.push(s);
657 + c._signals.push(s);
658 + }
659 +}
660 +for (const [slug, ind] of Object.entries(IND).slice(0, 8)) signals.push({ id: id('sig'), company_id: null, scope: 'industry', scope_key: slug, kind: 'ai_acceleration', strength: r1(0.3 + rnd() * 0.6), confidence: r1(0.5 + rnd() * 0.4), title: `AI hiring acceleration in ${ind.name}`, explanation: 'Share of AI-tagged listings rose across monitored companies in this industry.', evidence: { companies: ri(3, 12) }, window_days: 30, detected_at: iso(NOW - ri(1, 10) * DAY), status: 'active' });
661 +signals.push({ id: id('sig'), company_id: null, scope: 'global', scope_key: null, kind: 'pricing_migration', strength: 0.61, confidence: 0.72, title: 'SaaS pricing pages shifting to usage-based tiers', explanation: `${ri(8, 20)} monitored pricing pages added usage units in 30 days.`, evidence: { companies: 14 }, window_days: 30, detected_at: iso(NOW - 2 * DAY), status: 'active' });
662 +
663 +// trends
664 +const trends = TREND_TERMS.map((term) => ({ term, mentions: ri(12, 480), companies: ri(3, 30), momentum: r1((rnd() - 0.3) * 120), series: Array.from({ length: 14 }, () => ri(0, 40)) })).sort((a, b) => b.momentum - a.momentum);
665 +
666 +// industries and countries
667 +function industryRow(slug) {
668 + const ind = IND[slug];
669 + const cs = companies.filter((c) => c.industries.includes(slug));
670 + const evs = cs.flatMap((c) => perCompany.get(c.slug) ?? []).filter((e) => e.status === 'active');
671 + const avg = (k) => {
672 + const v = cs.map((c) => c.metrics[k]).filter((x) => typeof x === 'number');
673 + return v.length ? r1(v.reduce((a, b) => a + b, 0) / v.length) : null;
674 + };
675 + const byType = {};
676 + for (const e of evs.filter((e) => e.detected_at > iso(NOW - 30 * DAY))) byType[e.event_type] = (byType[e.event_type] ?? 0) + 1;
677 + return { slug, name: ind.name, parent_slug: ind.parent_slug, companies: cs.length, events_7d: evs.filter((e) => e.detected_at > iso(NOW - 7 * DAY)).length, events_30d: evs.filter((e) => e.detected_at > iso(NOW - 30 * DAY)).length, hiring_momentum_30d: avg('hiring_momentum_30d'), activity_score: avg('activity_score'), ai_adoption: avg('ai_adoption'), top_event_types: Object.entries(byType).sort((a, b) => b[1] - a[1]).slice(0, 3).map((x) => x[0]), _companies: cs, _events: evs };
678 +}
679 +function countryRow(code) {
680 + const [name, region, lat, lon] = COUNTRY_META[code];
681 + const cs = companies.filter((c) => c.country === code);
682 + const evs = cs.flatMap((c) => perCompany.get(c.slug) ?? []).filter((e) => e.status === 'active');
683 + const avg = (k) => {
684 + const v = cs.map((c) => c.metrics[k]).filter((x) => typeof x === 'number');
685 + return v.length ? r1(v.reduce((a, b) => a + b, 0) / v.length) : null;
686 + };
687 + const mix = {};
688 + for (const c of cs) for (const i of c.industries) mix[i] = (mix[i] ?? 0) + 1;
689 + return { code, name, region, companies: cs.length, events_7d: evs.filter((e) => e.detected_at > iso(NOW - 7 * DAY)).length, events_30d: evs.filter((e) => e.detected_at > iso(NOW - 30 * DAY)).length, hiring_momentum_30d: avg('hiring_momentum_30d'), activity_score: avg('activity_score'), industry_mix: Object.entries(mix).map(([industry, companies]) => ({ industry, companies })).sort((a, b) => b.companies - a.companies), lat, lon, _companies: cs, _events: evs };
690 +}
691 +const industryRows = () => Object.keys(IND).map(industryRow).filter((r) => r.companies > 0).sort((a, b) => b.events_30d - a.events_30d);
692 +const countryRows = () => Object.keys(COUNTRY_META).map(countryRow).filter((r) => r.companies > 0).sort((a, b) => b.events_30d - a.events_30d);
693 +
694 +// global daily history + index
695 +const history = [];
696 +{
697 + let idx = 100;
698 + for (let i = 364; i >= 0; i--) {
699 + const t = NOW - i * DAY;
700 + idx = clamp(idx + (rnd() - 0.48) * 3, 70, 150);
701 + const dayEvents = events.filter((e) => day(new Date(e.detected_at).getTime()) === day(t));
702 + const byType = {};
703 + for (const e of dayEvents) byType[e.event_type] = (byType[e.event_type] ?? 0) + 1;
704 + const growth = 1 - i / 600;
705 + history.push({ day: day(t), companies_active: Math.round(companies.length * growth), sensors_active: Math.round(sensors.size * growth), observations: Math.round((18000 + rnd() * 6000) * growth), changes: Math.round((900 + rnd() * 400) * growth), meaningful_changes: Math.round((260 + rnd() * 120) * growth), events: dayEvents.length, events_by_type: byType, jobs_open: Math.round(12000 * growth + rnd() * 800), jobs_new: ri(80, 400), jobs_removed: ri(60, 380), activity_index: r1(idx) });
706 + }
707 +}
708 +
709 +function stats() {
710 + const obs = [...sensors.values()].reduce((a, s) => a + s.observation_count, 0);
711 + const first = Math.min(...companies.map((c) => c._created));
712 + return { companies: companies.length, companies_active: companies.filter((c) => c.status === 'ACTIVE').length, sensors: sensors.size, sensors_active: [...sensors.values()].filter((s) => s.status === 'active').length, observations: obs + liveTicks * 37, snapshots: [...sensors.values()].reduce((a, s) => a + s.snapshot_count, 0), changes: changes.size + liveTicks * 3, meaningful_changes: [...changes.values()].filter((c) => c.significance >= 0.4).length + liveTicks, events: events.filter((e) => e.status === 'active').length, jobs_open: companies.reduce((a, c) => a + c.counts.jobs_open, 0), countries: new Set(companies.map((c) => c.country)).size, industries: new Set(companies.flatMap((c) => c.industries)).size, observations_today: 21560 + liveTicks * 37, changes_today: 1130 + liveTicks * 3, events_today: events.filter((e) => e.detected_at.slice(0, 10) === day(NOW) && e.status === 'active').length, dataset_started_at: iso(first), dataset_age_days: Math.round((NOW - first) / DAY), oldest_history_days: Math.round((NOW - first) / DAY), last_observation_at: iso(Date.now() - ri(3, 40) * 1000), archive: { objects: 1_284_310 + liveTicks * 30, bytes: 412_000_000_000 + liveTicks * 800_000 } };
713 +}
714 +
715 +function mapBuckets(metric) {
716 + const buckets = new Map();
717 + for (const c of companies) {
718 + const key = `${c.country}:${c.hq_city}`;
719 + if (!buckets.has(key)) buckets.set(key, { lat: c._lat, lon: c._lon, country: c.country, city: c.hq_city, companies: 0, events_30d: 0, jobs_open: 0, top: [] });
720 + const b = buckets.get(key);
721 + b.companies += 1;
722 + b.events_30d += (perCompany.get(c.slug) ?? []).filter((e) => e.detected_at > iso(NOW - 30 * DAY)).length;
723 + b.jobs_open += c.counts.jobs_open;
724 + b.top.push({ slug: c.slug, display_name: c.display_name, importance: c.importance });
725 + }
726 + for (const b of buckets.values()) b.top = b.top.sort((a, z) => z.importance - a.importance).slice(0, 3).map(({ slug, display_name }) => ({ slug, display_name }));
727 + return [...buckets.values()].sort((a, b) => b[metric] - a[metric]);
728 +}
729 +
730 +function rankings(kind, window, country, industry, limit) {
731 + let cs = companies.filter((c) => (!country || c.country === country.toUpperCase()) && (!industry || c.industries.includes(industry)));
732 + const wf = { '24h': 0.25, '7d': 0.6, '30d': 1, '90d': 1.3, '1y': 1.6 }[window] ?? 1;
733 + const val = (c) => {
734 + switch (kind) {
735 + case 'hiring_growth':
736 + return c.metrics.hiring_momentum_30d * wf;
737 + case 'hiring_decline':
738 + return c.metrics.hiring_momentum_30d * wf;
739 + case 'product_velocity':
740 + return c.metrics.product_velocity;
741 + case 'ai_active':
742 + return c.metrics.ai_adoption ?? -1;
743 + case 'geo_expansion':
744 + return c.metrics.geo_expansion;
745 + case 'developer_momentum':
746 + return c.metrics.developer_momentum ?? -1;
747 + case 'pricing_changes':
748 + return (perCompany.get(c.slug) ?? []).filter((e) => e.event_type === 'PRICING').length * wf;
749 + case 'unusual_activity':
750 + return c.metrics.anomaly_score;
751 + default:
752 + return c.metrics.activity_score;
753 + }
754 + };
755 + cs = cs.filter((c) => val(c) >= 0);
756 + cs.sort((a, b) => (kind === 'hiring_decline' ? val(a) - val(b) : val(b) - val(a)));
757 + return cs.slice(0, limit).map((c, i) => ({ ...pub(c), rank: i + 1, value: r1(val(c)), delta: chance(0.8) ? r1((rnd() - 0.5) * 20) : null }));
758 +}
759 +
760 +const pub = (c) => {
761 + const { _lat, _lon, _created, _sensors, _jobs, _people, _products, _plans, _locations, _news, _series, _hir90, _signals, _aliases, ...rest } = c;
762 + return rest;
763 +};
764 +const pubSensor = (s) => {
765 + const { _versions, _changes, ...rest } = s;
766 + return rest;
767 +};
768 +const pubChange = (c) => {
769 + const { _events, ...rest } = c;
770 + return rest;
771 +};
772 +const pubSnap = (s) => {
773 + const { _blocks, _text, ...rest } = s;
774 + return rest;
775 +};
776 +const ref = (c) => ({ id: c.id, slug: c.slug, display_name: c.display_name, canonical_domain: c.canonical_domain, country: c.country, logo_url: null });
777 +
778 +// ------------------------------------------------------------------------------------------------ live stream
779 +let liveTicks = 0;
780 +const sseClients = new Set();
781 +setInterval(() => {
782 + const c = pick(companies);
783 + const ev = makeEvent(c, pick(EVENT_TEMPLATES), Date.now() - ri(0, 2000), { live: true });
784 + events.unshift(ev);
785 + eventsById.set(ev.id, ev);
786 + (perCompany.get(c.slug) ?? perCompany.set(c.slug, []).get(c.slug)).unshift(ev);
787 + c.counts.events += 1;
788 + c.last_event_at = ev.detected_at;
789 + liveTicks += 1;
790 + const frame = `event: event\nid: ${ev.id}\ndata: ${JSON.stringify(ev)}\n\n`;
791 + for (const res of sseClients) res.write(frame);
792 +}, 4000);
793 +setInterval(() => {
794 + for (const res of sseClients) res.write(`event: heartbeat\ndata: ${JSON.stringify({ at: new Date().toISOString(), clients: sseClients.size })}\n\n`);
795 +}, 20000);
796 +
797 +// ------------------------------------------------------------------------------------------------ owner / admin state
798 +const watchlists = new Map(); // token -> Set(slug)
799 +const alerts = new Map(); // token -> Alert[]
800 +const adminToken = process.env.CA_ADMIN_TOKEN ?? 'dev-admin-token';
801 +
802 +// ------------------------------------------------------------------------------------------------ helpers
803 +function paginate(items, q, defaultPer = 25) {
804 + const page = Math.max(1, Number(q.get('page') ?? 1));
805 + const per = clamp(Number(q.get('per_page') ?? defaultPer), 1, 200);
806 + const total = items.length;
807 + return { items: items.slice((page - 1) * per, page * per), page, per_page: per, total, pages: Math.max(1, Math.ceil(total / per)) };
808 +}
809 +function filterEvents(list, q) {
810 + let out = list;
811 + const g = (k) => q.get(k);
812 + if (g('event_type')) out = out.filter((e) => e.event_type === g('event_type').toUpperCase());
813 + if (g('event_subtype')) out = out.filter((e) => e.event_subtype === g('event_subtype').toUpperCase());
814 + if (g('country')) out = out.filter((e) => (e.company.country ?? '').toUpperCase() === g('country').toUpperCase());
815 + if (g('industry')) out = out.filter((e) => companiesBySlug.get(e.company.slug)?.industries.includes(g('industry')));
816 + if (g('since')) out = out.filter((e) => e.detected_at > g('since'));
817 + if (g('until')) out = out.filter((e) => e.detected_at < g('until'));
818 + if (g('min_importance')) out = out.filter((e) => e.importance >= Number(g('min_importance')));
819 + if (g('min_confidence')) out = out.filter((e) => e.confidence >= Number(g('min_confidence')));
820 + if (g('surface')) out = out.filter((e) => e.surface === g('surface'));
821 + if (g('origin')) out = out.filter((e) => e.origin === g('origin'));
822 + if (g('company')) out = out.filter((e) => e.company.slug === g('company'));
823 + if (g('status')) out = out.filter((e) => e.status === g('status'));
824 + if (g('q')) {
825 + const s = g('q').toLowerCase();
826 + out = out.filter((e) => e.title.toLowerCase().includes(s) || (e.summary ?? '').toLowerCase().includes(s) || e.company.display_name.toLowerCase().includes(s));
827 + }
828 + if (g('sort') === 'importance') out = [...out].sort((a, b) => b.importance - a.importance);
829 + return out;
830 +}
831 +const TIMELINE_MAP = { products: ['PRODUCT'], jobs: ['HIRING'], pricing: ['PRICING'], leadership: ['LEADERSHIP'], locations: ['LOCATION'], legal: ['LEGAL'], news: ['COMMUNICATION', 'PARTNERSHIP', 'M&A', 'FINANCING', 'INVESTOR_RELATIONS'], developer: ['DEVELOPER', 'TECHNOLOGY'] };
832 +
833 +function json(res, status, body, extra = {}) {
834 + const data = JSON.stringify(body);
835 + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type, x-ca-owner-token, x-ca-admin-token', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', 'x-api-version': 'mock-1', ...extra });
836 + res.end(data);
837 +}
838 +const notFound = (res, what = 'not found') => json(res, 404, { detail: what });
839 +function readBody(req) {
840 + return new Promise((resolve) => {
841 + let b = '';
842 + req.on('data', (c) => (b += c));
843 + req.on('end', () => {
844 + try {
845 + resolve(b ? JSON.parse(b) : {});
846 + } catch {
847 + resolve({});
848 + }
849 + });
850 + });
851 +}
852 +function csv(rows) {
853 + if (!rows.length) return '';
854 + const keys = Object.keys(rows[0]).filter((k) => typeof rows[0][k] !== 'object' || rows[0][k] === null);
855 + const esc = (v) => (v === null || v === undefined ? '' : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
856 + return [keys.join(','), ...rows.map((r) => keys.map((k) => esc(r[k])).join(','))].join('\n');
857 +}
858 +
859 +// ------------------------------------------------------------------------------------------------ router
860 +const server = createServer(async (req, res) => {
861 + const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
862 + const q = url.searchParams;
863 + let path = url.pathname.replace(/\/$/, '') || '/';
864 + if (req.method === 'OPTIONS') return json(res, 204, {});
865 + if (path === '/health' || path === '/ready' || path === '/api/v1/health' || path === '/api/v1/ready') return json(res, 200, { status: 'ok', service: 'company-atlas-mock', time: new Date().toISOString() });
866 + if (!path.startsWith('/api/v1')) return notFound(res);
867 + path = path.slice('/api/v1'.length) || '/';
868 + const seg = path.split('/').filter(Boolean);
869 + const owner = req.headers['x-ca-owner-token'];
870 + const admin = req.headers['x-ca-admin-token'];
871 +
872 + try {
873 + // ---- platform
874 + if (path === '/stats') return json(res, 200, stats(), { 'cache-control': 'public, max-age=60' });
875 + if (path === '/stats/history') return json(res, 200, { items: history.slice(-clamp(Number(q.get('days') ?? 90), 1, 365)) });
876 + if (path === '/system') return json(res, 200, { sensors_online: [...sensors.values()].filter((s) => s.status === 'active').length, sensors_failing: [...sensors.values()].filter((s) => s.status === 'failing').length, observations_today: 21560 + liveTicks * 37, events_today: stats().events_today, countries_covered: new Set(companies.map((c) => c.country)).size, queue_lag_s: ri(2, 40), scheduler_last_tick_at: iso(Date.now() - ri(1, 20) * 1000), fetch_per_min: ri(280, 460), success_rate_24h: r1(96 + rnd() * 3.5) });
877 + if (path === '/pulse') {
878 + const active = events.filter((e) => e.status === 'active');
879 + const idxSeries = history.slice(-30).map((h) => ({ day: h.day, value: h.activity_index, confidence: 0.9 }));
880 + const last = idxSeries[idxSeries.length - 1].value;
881 + const wk = idxSeries[idxSeries.length - 8].value;
882 + return json(res, 200, { stats: stats(), live: active.slice(0, 12), movers: [...companies].sort((a, b) => b.metrics.corporate_change_index - a.metrics.corporate_change_index).slice(0, 10).map(pub), hiring: [...companies].sort((a, b) => b.metrics.hiring_momentum_30d - a.metrics.hiring_momentum_30d).slice(0, 8).map(pub), launches: active.filter((e) => e.event_subtype === 'PRODUCT_LAUNCH').slice(0, 8), pricing: active.filter((e) => e.event_type === 'PRICING').slice(0, 8), ai: [...companies].filter((c) => typeof c.metrics.ai_adoption === 'number').sort((a, b) => b.metrics.ai_adoption - a.metrics.ai_adoption).slice(0, 8).map(pub), industries: industryRows().slice(0, 12).map(({ _companies, _events, ...r }) => r), countries: countryRows().slice(0, 12).map(({ _companies, _events, ...r }) => r), trending: trends.slice(0, 10), activity_index: { value: last, delta_7d: r1(((last - wk) / wk) * 100), series: idxSeries }, map: mapBuckets('events_30d') }, { 'cache-control': 'public, max-age=60' });
883 + }
884 + if (path === '/live') {
885 + const limit = clamp(Number(q.get('limit') ?? 50), 1, 500);
886 + let list = events.filter((e) => e.status === 'active');
887 + list = filterEvents(list, q);
888 + return json(res, 200, { items: list.slice(0, limit) }, { 'cache-control': 'no-store' });
889 + }
890 + if (path === '/live/stream') {
891 + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store, no-transform', connection: 'keep-alive', 'access-control-allow-origin': '*', 'x-accel-buffering': 'no' });
892 + res.write(`: connected\n\n`);
893 + const since = q.get('since');
894 + if (since) for (const e of filterEvents(events.filter((e) => e.status === 'active' && e.detected_at > since), q).slice(0, 20).reverse()) res.write(`event: event\nid: ${e.id}\ndata: ${JSON.stringify(e)}\n\n`);
895 + sseClients.add(res);
896 + req.on('close', () => sseClients.delete(res));
897 + return;
898 + }
899 +
900 + // ---- companies
901 + if (path === '/companies/compare') {
902 + const slugs = (q.get('companies') ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6);
903 + const cs = slugs.map((s) => companiesBySlug.get(s)).filter(Boolean);
904 + if (cs.length < 2) return json(res, 400, { detail: 'compare needs 2–6 known companies' });
905 + const metrics = {};
906 + for (const k of ['activity_score', 'hiring_momentum_30d', 'product_velocity', 'ai_adoption', 'geo_expansion', 'developer_momentum', 'corporate_change_index', 'open_jobs']) {
907 + metrics[k] = {};
908 + for (const c of cs) if (typeof c.metrics[k] === 'number') metrics[k][c.slug] = c.metrics[k];
909 + }
910 + const seriesOut = {};
911 + const events_30d = {};
912 + const jobs = {};
913 + const locations = {};
914 + for (const c of cs) {
915 + seriesOut[c.slug] = c._series.activity_score;
916 + const by = {};
917 + for (const e of (perCompany.get(c.slug) ?? []).filter((e) => e.detected_at > iso(NOW - 30 * DAY) && e.status === 'active')) by[e.event_type] = (by[e.event_type] ?? 0) + 1;
918 + events_30d[c.slug] = by;
919 + jobs[c.slug] = { open: c.counts.jobs_open, ai_open: c._jobs.filter((j) => j.status === 'open' && j.is_ai).length, new_30d: c._jobs.filter((j) => j.first_seen_at > iso(NOW - 30 * DAY)).length };
920 + locations[c.slug] = c._locations.filter((l) => l.status === 'listed').length;
921 + }
922 + return json(res, 200, { companies: cs.map(pub), metrics, series: seriesOut, events_30d, jobs, locations });
923 + }
924 + if (path === '/companies') {
925 + let list = [...companies];
926 + const g = (k) => q.get(k);
927 + if (g('q')) {
928 + const s = g('q').toLowerCase();
929 + list = list.filter((c) => c.display_name.toLowerCase().includes(s) || c.canonical_domain.includes(s) || c._aliases.some((a) => a.toLowerCase().includes(s)));
930 + }
931 + if (g('country')) list = list.filter((c) => c.country === g('country').toUpperCase());
932 + if (g('industry')) list = list.filter((c) => c.industries.includes(g('industry')));
933 + if (g('tier')) list = list.filter((c) => String(c.tier) === g('tier'));
934 + if (g('public')) list = list.filter((c) => c.public_company === (g('public') === '1' || g('public') === 'true'));
935 + if (g('status')) list = list.filter((c) => c.status.toLowerCase() === g('status').toLowerCase());
936 + if (g('has_events')) list = list.filter((c) => (c.counts.events > 0) === (g('has_events') === '1' || g('has_events') === 'true'));
937 + const sort = g('sort') ?? 'activity';
938 + const cmp = { activity: (a, b) => b.metrics.activity_score - a.metrics.activity_score, events: (a, b) => b.counts.events - a.counts.events, hiring: (a, b) => b.metrics.hiring_momentum_30d - a.metrics.hiring_momentum_30d, name: (a, b) => a.display_name.localeCompare(b.display_name), importance: (a, b) => b.importance - a.importance, recent: (a, b) => (b.last_event_at ?? '').localeCompare(a.last_event_at ?? '') }[sort];
939 + if (cmp) list.sort(cmp);
940 + const page = paginate(list, q);
941 + const spark = g('sparkline') === '1';
942 + page.items = page.items.map((c) => {
943 + const p = pub(c);
944 + if (!spark) delete p.sparkline;
945 + return p;
946 + });
947 + return json(res, 200, page);
948 + }
949 + if (seg[0] === 'companies' && seg[1]) {
950 + const c = companiesBySlug.get(decodeURIComponent(seg[1]));
951 + if (!c) return notFound(res, 'company not found');
952 + const sub = seg[2];
953 + const list = perCompany.get(c.slug) ?? [];
954 + if (!sub) {
955 + return json(res, 200, { ...pub(c), aliases: c._aliases, domains: [{ domain: c.canonical_domain, kind: 'canonical' }, { domain: `docs.${c.canonical_domain}`, kind: 'subdomain' }, { domain: `status.${c.canonical_domain}`, kind: 'subdomain' }], relationships: [{ kind: 'COMPETITOR', company: companies.find((o) => o !== c && o.industries[0] === c.industries[0]) ? ref(companies.find((o) => o !== c && o.industries[0] === c.industries[0])) : null, to_name: null, valid_from: null, valid_to: null, confidence: 0.6 }, { kind: 'PARTNER', company: null, to_name: pick(PARTNERS), valid_from: iso(NOW - 200 * DAY), valid_to: null, confidence: 0.8 }], metrics_detail: Object.entries(c.metrics).map(([metric, value]) => ({ metric, value, confidence: r1(0.6 + rnd() * 0.35), computed_at: iso(NOW - ri(5, 120) * 60_000), formula_version: 'v1.0', inputs: { sensors: c.counts.sensors, events_30d: list.filter((e) => e.detected_at > iso(NOW - 30 * DAY)).length } })), sensors_by_surface: c._sensors.reduce((a, s) => ((a[s.surface] = (a[s.surface] ?? 0) + 1), a), {}), coverage: { historical_coverage: c.metrics.historical_coverage, first_observed_at: iso(c._created), days_observed: Math.round((NOW - c._created) / DAY), sensor_uptime: r1(90 + rnd() * 9.5) }, signals: c._signals, sparklines: { activity_30d: c.sparkline, hiring_90d: c._hir90 } });
956 + }
957 + if (sub === 'events') return json(res, 200, paginate(filterEvents(list, q), q));
958 + if (sub === 'timeline') {
959 + const f = q.get('filter') ?? 'all';
960 + const limit = clamp(Number(q.get('limit') ?? 200), 1, 500);
961 + const types = TIMELINE_MAP[f];
962 + const items = (types ? list.filter((e) => types.includes(e.event_type)) : list).slice(0, limit).map((e) => ({ ...e, day: e.detected_at.slice(0, 10) }));
963 + const days = {};
964 + for (const e of items) days[e.day] = (days[e.day] ?? 0) + 1;
965 + return json(res, 200, { items, days: Object.entries(days).map(([day, count]) => ({ day, count })) });
966 + }
967 + if (sub === 'metrics') {
968 + const days = clamp(Number(q.get('days') ?? 90), 7, 365);
969 + const s = {};
970 + for (const [k, v] of Object.entries(c._series)) s[k] = v.slice(-days);
971 + return json(res, 200, { current: Object.entries(c.metrics).map(([metric, value]) => ({ metric, value, confidence: r1(0.6 + rnd() * 0.35), computed_at: iso(NOW - ri(5, 120) * 60_000), formula_version: 'v1.0', inputs: {} })), series: s });
972 + }
973 + if (sub === 'jobs') {
974 + let jobs = [...c._jobs];
975 + const st = q.get('status') ?? 'open';
976 + if (st === 'open') jobs = jobs.filter((j) => j.status === 'open');
977 + if (st === 'removed') jobs = jobs.filter((j) => j.status === 'no_longer_listed');
978 + if (q.get('q')) jobs = jobs.filter((j) => j.title.toLowerCase().includes(q.get('q').toLowerCase()));
979 + if (q.get('country')) jobs = jobs.filter((j) => j.country === q.get('country').toUpperCase());
980 + if (q.get('ai') === '1') jobs = jobs.filter((j) => j.is_ai);
981 + jobs.sort((a, b) => b.first_seen_at.localeCompare(a.first_seen_at));
982 + const open = c._jobs.filter((j) => j.status === 'open');
983 + const byC = {};
984 + const byD = {};
985 + for (const j of open) {
986 + byC[j.country] = (byC[j.country] ?? 0) + 1;
987 + byD[j.department] = (byD[j.department] ?? 0) + 1;
988 + }
989 + const page = paginate(jobs, q, 50);
990 + page.meta = { summary: { open: open.length, new_7d: c._jobs.filter((j) => j.first_seen_at > iso(NOW - 7 * DAY)).length, removed_7d: c._jobs.filter((j) => j.removed_at && j.removed_at > iso(NOW - 7 * DAY)).length, ai_open: open.filter((j) => j.is_ai).length, by_country: Object.entries(byC).map(([country, n]) => ({ country, n })).sort((a, b) => b.n - a.n), by_department: Object.entries(byD).map(([department, n]) => ({ department, n })).sort((a, b) => b.n - a.n), remote_ratio: open.length ? r1(open.filter((j) => j.remote).length / open.length) : null } };
991 + return json(res, 200, page);
992 + }
993 + if (sub === 'people') return json(res, 200, { listed: c._people.filter((p) => p.status === 'listed'), no_longer_listed: c._people.filter((p) => p.status !== 'listed') });
994 + if (sub === 'products') return json(res, 200, { listed: c._products.filter((p) => p.status === 'listed'), removed: c._products.filter((p) => p.status !== 'listed') });
995 + if (sub === 'pricing') return json(res, 200, { current: c._plans.filter((p) => p.status === 'current'), history: c._plans.filter((p) => p.status !== 'current') });
996 + if (sub === 'locations') return json(res, 200, { items: c._locations, countries: [...new Set(c._locations.filter((l) => l.status === 'listed').map((l) => l.country))] });
997 + if (sub === 'news') return json(res, 200, { items: [...c._news].sort((a, b) => b.published_at.localeCompare(a.published_at)).slice(0, clamp(Number(q.get('limit') ?? 50), 1, 200)) });
998 + if (sub === 'sensors') return json(res, 200, { items: c._sensors.map(pubSensor) });
999 + if (sub === 'history') return json(res, 200, { sensors: c._sensors.filter((s) => s._versions.length).map((s) => ({ ...pubSensor(s), versions: [...s._versions].reverse().slice(0, 20).map(pubSnap) })) });
1000 + if (sub === 'similar') return json(res, 200, { items: companies.filter((o) => o !== c && (o.industries.some((i) => c.industries.includes(i)) || o.country === c.country)).sort((a, b) => Math.abs(a.importance - c.importance) - Math.abs(b.importance - c.importance)).slice(0, clamp(Number(q.get('limit') ?? 8), 1, 24)).map(pub) });
1001 + return notFound(res);
1002 + }
1003 +
1004 + // ---- provenance
1005 + if (seg[0] === 'sensors' && seg[1]) {
1006 + const s = sensors.get(seg[1]);
1007 + if (!s) return notFound(res, 'sensor not found');
1008 + const c = companies.find((x) => x.id === s.company_id);
1009 + if (!seg[2]) return json(res, 200, { ...pubSensor(s), company: ref(c), latest_snapshot: s._versions.length ? pubSnap(s._versions[s._versions.length - 1]) : null });
1010 + if (seg[2] === 'snapshots') return json(res, 200, { items: [...s._versions].reverse().slice(0, clamp(Number(q.get('limit') ?? 50), 1, 200)).map(pubSnap) });
1011 + if (seg[2] === 'changes') return json(res, 200, { items: [...s._changes].reverse().slice(0, clamp(Number(q.get('limit') ?? 50), 1, 200)).map(pubChange) });
1012 + }
1013 + if (seg[0] === 'snapshots' && seg[1]) {
1014 + const s = snapshots.get(seg[1]);
1015 + if (!s) return notFound(res, 'snapshot not found');
1016 + if (seg[2] === 'diff' && seg[3]) {
1017 + const o = snapshots.get(seg[3]);
1018 + if (!o) return notFound(res, 'snapshot not found');
1019 + const before = s.fetched_at < o.fetched_at ? s : o;
1020 + const after = before === s ? o : s;
1021 + const existing = [...changes.values()].find((c) => c.snapshot_before === before.id && c.snapshot_after === after.id);
1022 + const diff = existing ? existing.diff : { added: after._blocks.slice(0, 2).map((b) => ({ key: b.key, kind: b.kind, path: b.path, before: null, after: b.text, weight: 0.5, similarity: null })), removed: before._blocks.slice(-1).map((b) => ({ key: b.key, kind: b.kind, path: b.path, before: b.text, after: null, weight: 0.5, similarity: null })), modified: before._blocks.slice(1, 3).map((b, i) => ({ key: b.key, kind: b.kind, path: b.path, before: b.text, after: after._blocks[i + 1]?.text ?? '', weight: 0.5, similarity: 0.6 })), moved: [], counts: { added: 2, removed: 1, modified: 2 }, text_delta_ratio: 0.18, similarity: 0.81, reasons: ['computed on demand between non-adjacent versions'] };
1023 + return json(res, 200, { before: pubSnap(before), after: pubSnap(after), diff });
1024 + }
1025 + return json(res, 200, { ...pubSnap(s), text: s._text, blocks: s._blocks, extracted: s.extracted_summary });
1026 + }
1027 + if (seg[0] === 'changes' && seg[1]) {
1028 + const ch = changes.get(seg[1]);
1029 + if (!ch) return notFound(res, 'change not found');
1030 + const c = companies.find((x) => x.id === ch.company_id);
1031 + return json(res, 200, { ...pubChange(ch), events: ch._events.map((id) => eventsById.get(id)).filter(Boolean), company: ref(c) });
1032 + }
1033 +
1034 + // ---- events
1035 + if (path === '/events/types') {
1036 + const cutoff = iso(NOW - 30 * DAY);
1037 + const byType = {};
1038 + for (const e of events.filter((e) => e.detected_at > cutoff && e.status === 'active')) {
1039 + byType[e.event_type] ??= { event_type: e.event_type, count_30d: 0, subtypes: {} };
1040 + byType[e.event_type].count_30d += 1;
1041 + byType[e.event_type].subtypes[e.event_subtype] = (byType[e.event_type].subtypes[e.event_subtype] ?? 0) + 1;
1042 + }
1043 + return json(res, 200, { types: Object.values(byType).map((t) => ({ ...t, subtypes: Object.entries(t.subtypes).map(([event_subtype, count_30d]) => ({ event_subtype, count_30d })) })).sort((a, b) => b.count_30d - a.count_30d) });
1044 + }
1045 + if (path === '/events/summary') {
1046 + const days = clamp(Number(q.get('days') ?? 7), 1, 365);
1047 + const group = q.get('group') ?? 'type';
1048 + const cur = events.filter((e) => e.detected_at > iso(NOW - days * DAY) && e.status === 'active');
1049 + const prev = events.filter((e) => e.detected_at > iso(NOW - 2 * days * DAY) && e.detected_at <= iso(NOW - days * DAY) && e.status === 'active');
1050 + const keyOf = (e) => (group === 'country' ? e.company.country : group === 'industry' ? companiesBySlug.get(e.company.slug)?.industry_primary : e.event_type);
1051 + const count = (list) => list.reduce((a, e) => ((a[keyOf(e)] = (a[keyOf(e)] ?? 0) + 1), a), {});
1052 + const a = count(cur);
1053 + const b = count(prev);
1054 + return json(res, 200, { items: Object.entries(a).map(([key, count]) => ({ key, count, delta_pct: b[key] ? r1(((count - b[key]) / b[key]) * 100) : null })).sort((x, y) => y.count - x.count) });
1055 + }
1056 + if (path === '/events') return json(res, 200, paginate(filterEvents(events.filter((e) => q.get('status') ? true : e.status !== 'duplicate'), q), q));
1057 + if (seg[0] === 'events' && seg[1]) {
1058 + const e = eventsById.get(seg[1]);
1059 + if (!e) return notFound(res, 'event not found');
1060 + const ch = e.change_id ? changes.get(e.change_id) : null;
1061 + return json(res, 200, { ...e, change: ch ? pubChange({ ...ch, diff: undefined, structured_delta: undefined }) : null });
1062 + }
1063 +
1064 + // ---- rankings, atlases
1065 + if (path === '/rankings') return json(res, 200, { kind: q.get('kind') ?? 'most_active', window: q.get('window') ?? '30d', items: rankings(q.get('kind') ?? 'most_active', q.get('window') ?? '30d', q.get('country'), q.get('industry'), clamp(Number(q.get('limit') ?? 50), 1, 200)) }, { 'cache-control': 'public, max-age=60' });
1066 + if (path === '/industries') return json(res, 200, { items: industryRows().map(({ _companies, _events, ...r }) => r) });
1067 + if (seg[0] === 'industries' && seg[1]) {
1068 + if (!IND[seg[1]]) return notFound(res, 'industry not found');
1069 + const r = industryRow(seg[1]);
1070 + const cs = r._companies;
1071 + const open = cs.reduce((a, c) => a + c.counts.jobs_open, 0);
1072 + const { _companies, _events, ...row } = r;
1073 + const cc = {};
1074 + for (const c of cs) cc[c.country] = (cc[c.country] ?? 0) + 1;
1075 + return json(res, 200, { ...row, description: `Monitored companies classified under ${row.name}.`, companies: [...cs].sort((a, b) => b.metrics.activity_score - a.metrics.activity_score).slice(0, 24).map(pub), events: _events.slice(0, 20), hiring: { open, new_30d: ri(20, 400), removed_30d: ri(10, 300), momentum_30d: row.hiring_momentum_30d }, series: series(90, row.activity_score ?? 50, 6), countries: Object.entries(cc).map(([country, companies]) => ({ country, companies })).sort((a, b) => b.companies - a.companies), trending: trends.slice(0, 8) });
1076 + }
1077 + if (path === '/countries') return json(res, 200, { items: countryRows().map(({ _companies, _events, ...r }) => r) });
1078 + if (seg[0] === 'countries' && seg[1]) {
1079 + const code = seg[1].toUpperCase();
1080 + if (!COUNTRY_META[code]) return notFound(res, 'country not found');
1081 + const r = countryRow(code);
1082 + const { _companies, _events, ...row } = r;
1083 + const cs = r._companies;
1084 + return json(res, 200, { ...row, companies: [...cs].sort((a, b) => b.metrics.activity_score - a.metrics.activity_score).slice(0, 24).map(pub), events: _events.slice(0, 20), movers: [...cs].sort((a, b) => b.metrics.corporate_change_index - a.metrics.corporate_change_index).slice(0, 10).map(pub), new_entrants: [...cs].sort((a, b) => b._created - a._created).slice(0, 10).map(pub), series: series(90, row.activity_score ?? 50, 6), industries: industryRows().filter((i) => cs.some((c) => c.industries.includes(i.slug))).map(({ _companies, _events, ...x }) => x) });
1085 + }
1086 + if (path === '/signals') {
1087 + let list = signals;
1088 + if (q.get('kind')) list = list.filter((s) => s.kind === q.get('kind'));
1089 + if (q.get('scope')) list = list.filter((s) => s.scope === q.get('scope'));
1090 + return json(res, 200, { items: list.slice(0, clamp(Number(q.get('limit') ?? 50), 1, 200)) });
1091 + }
1092 + if (path === '/trends') return json(res, 200, { items: trends.slice(0, clamp(Number(q.get('limit') ?? 30), 1, 100)) });
1093 + if (path === '/map') return json(res, 200, { buckets: mapBuckets(q.get('metric') === 'companies' ? 'companies' : q.get('metric') === 'hiring' ? 'jobs_open' : 'events_30d') });
1094 + if (path === '/index') {
1095 + const s = history.map((h) => ({ day: h.day, value: h.activity_index, confidence: 0.9 }));
1096 + const last = s[s.length - 1].value;
1097 + const byType = {};
1098 + for (const e of events.filter((e) => e.detected_at > iso(NOW - 30 * DAY))) byType[e.event_type] = (byType[e.event_type] ?? 0) + 1;
1099 + return json(res, 200, { value: last, baseline: 100, delta_7d: r1(((last - s[s.length - 8].value) / s[s.length - 8].value) * 100), delta_30d: r1(((last - s[s.length - 31].value) / s[s.length - 31].value) * 100), series: s, by_type: byType, by_country: countryRows().slice(0, 12).map((c) => ({ key: c.code, value: r1(80 + rnd() * 60) })), by_industry: industryRows().slice(0, 12).map((i) => ({ key: i.slug, value: r1(80 + rnd() * 60) })), formula_version: 'gcai-v1.0' });
1100 + }
1101 +
1102 + // ---- search
1103 + if (path === '/search/suggest') {
1104 + const s = (q.get('q') ?? '').toLowerCase().trim();
1105 + const items = [];
1106 + if (s) {
1107 + for (const c of companies) if (c.display_name.toLowerCase().includes(s) || c.canonical_domain.includes(s)) items.push({ kind: 'company', label: c.display_name, sublabel: `${c.canonical_domain} · ${c.country}`, href: `/company/${c.slug}` });
1108 + for (const i of Object.values(IND)) if (i.name.toLowerCase().includes(s)) items.push({ kind: 'industry', label: i.name, sublabel: 'Industry', href: `/industry/${i.slug}` });
1109 + for (const [code, [name]] of Object.entries(COUNTRY_META)) if (name.toLowerCase().includes(s) || code.toLowerCase() === s) items.push({ kind: 'country', label: name, sublabel: code, href: `/country/${code.toLowerCase()}` });
1110 + for (const t of [...new Set(EVENT_TEMPLATES.map((t) => t[0]))]) if (t.toLowerCase().includes(s)) items.push({ kind: 'event_type', label: t, sublabel: 'Event type', href: `/events?event_type=${encodeURIComponent(t)}` });
1111 + }
1112 + return json(res, 200, { items: items.slice(0, 10) }, { 'cache-control': 'no-store' });
1113 + }
1114 + if (path === '/search') {
1115 + const s = (q.get('q') ?? '').toLowerCase().trim();
1116 + const t0 = performance.now();
1117 + const limit = clamp(Number(q.get('limit') ?? 10), 1, 50);
1118 + const words = s.split(/\s+/).filter(Boolean);
1119 + const hit = (txt) => words.some((w) => txt.toLowerCase().includes(w));
1120 + const cs = companies.filter((c) => hit(c.display_name) || hit(c.canonical_domain) || c.industries.some((i) => hit(IND[i].name)) || hit(COUNTRY_META[c.country][0]));
1121 + return json(res, 200, { query: q.get('q') ?? '', companies: cs.slice(0, limit).map(pub), events: events.filter((e) => e.status === 'active' && (hit(e.title) || hit(e.summary ?? ''))).slice(0, limit), industries: Object.keys(IND).filter((k) => hit(IND[k].name)).map(industryRow).map(({ _companies, _events, ...r }) => r), countries: Object.keys(COUNTRY_META).filter((k) => hit(COUNTRY_META[k][0])).map(countryRow).map(({ _companies, _events, ...r }) => r), people: companies.flatMap((c) => c._people.filter((p) => hit(p.name) || hit(p.title ?? '')).map((p) => ({ ...p, company: ref(c) }))).slice(0, limit), products: companies.flatMap((c) => c._products.filter((p) => hit(p.name)).map((p) => ({ ...p, company: ref(c) }))).slice(0, limit), took_ms: Math.round(performance.now() - t0) }, { 'cache-control': 'no-store' });
1122 + }
1123 + if (path === '/ask') {
1124 + const s = (q.get('q') ?? '').toLowerCase();
1125 + const filters = {};
1126 + for (const [code, [name]] of Object.entries(COUNTRY_META)) if (s.includes(name.toLowerCase())) filters.country = code;
1127 + for (const i of Object.values(IND)) if (s.includes(i.name.toLowerCase())) filters.industry = i.slug;
1128 + if (/\bai\b|machine learning|llm/.test(s)) filters.ai = true;
1129 + if (/hiring|jobs|engineer/.test(s)) filters.event_type = 'HIRING';
1130 + if (/pric/.test(s)) filters.event_type = 'PRICING';
1131 + if (/office|expan|countr/.test(s)) filters.event_type = 'LOCATION';
1132 + if (/launch|product/.test(s)) filters.event_type = 'PRODUCT';
1133 + if (/leader|exec|ceo|cto/.test(s)) filters.event_type = 'LEADERSHIP';
1134 + let cs = companies.filter((c) => (!filters.country || c.country === filters.country) && (!filters.industry || c.industries.includes(filters.industry)));
1135 + if (filters.ai) cs = cs.filter((c) => (c.metrics.ai_adoption ?? 0) > 40 || c._jobs.some((j) => j.is_ai && j.status === 'open'));
1136 + cs.sort((a, b) => b.metrics.activity_score - a.metrics.activity_score);
1137 + let evs = events.filter((e) => e.status === 'active' && (!filters.event_type || e.event_type === filters.event_type) && (!filters.country || e.company.country === filters.country) && (!filters.industry || companiesBySlug.get(e.company.slug)?.industries.includes(filters.industry)));
1138 + const answer = cs.length ? `${cs.length} monitored ${cs.length === 1 ? 'company matches' : 'companies match'} this question${filters.country ? ` in ${COUNTRY_META[filters.country][0]}` : ''}${filters.industry ? ` (${IND[filters.industry].name})` : ''}. ${evs.length ? `${Math.min(evs.length, 200)} related structured events were detected in the monitored record; the most recent are listed below with their sources.` : 'No related structured events were detected yet.'} This is a routed structured query over observed public pages, not an opinion.` : 'No monitored company matches this question yet. Try a broader industry or country, or search company names directly.';
1139 + return json(res, 200, { interpretation: filters, answer, companies: cs.slice(0, 10).map(pub), events: evs.slice(0, 10), sources: evs.slice(0, 10).map((e) => e.source_url).filter(Boolean) }, { 'cache-control': 'no-store' });
1140 + }
1141 +
1142 + // ---- watchlist & alerts
1143 + if (path.startsWith('/watchlist') || path.startsWith('/alerts')) {
1144 + if (!owner || String(owner).length < 24) return json(res, 401, { detail: 'X-CA-Owner-Token required (≥ 24 chars)' });
1145 + const key = String(owner);
1146 + if (!watchlists.has(key)) watchlists.set(key, new Set());
1147 + if (!alerts.has(key)) alerts.set(key, []);
1148 + const wl = watchlists.get(key);
1149 + if (path === '/watchlist' && req.method === 'GET') {
1150 + const cs = [...wl].map((s) => companiesBySlug.get(s)).filter(Boolean);
1151 + const evs = events.filter((e) => e.status === 'active' && wl.has(e.company.slug)).slice(0, 30);
1152 + return json(res, 200, { items: cs.map(pub), events: evs }, { 'cache-control': 'no-store' });
1153 + }
1154 + if (path === '/watchlist' && req.method === 'POST') {
1155 + const body = await readBody(req);
1156 + const c = companiesBySlug.get(body.company);
1157 + if (!c) return notFound(res, 'company not found');
1158 + wl.add(c.slug);
1159 + return json(res, 201, { ok: true, company: c.slug, items: wl.size });
1160 + }
1161 + if (seg[0] === 'watchlist' && seg[1] && req.method === 'DELETE') {
1162 + wl.delete(decodeURIComponent(seg[1]));
1163 + return json(res, 200, { ok: true, items: wl.size });
1164 + }
1165 + const al = alerts.get(key);
1166 + if (path === '/alerts' && req.method === 'GET') return json(res, 200, { items: al }, { 'cache-control': 'no-store' });
1167 + if (path === '/alerts' && req.method === 'POST') {
1168 + const body = await readBody(req);
1169 + if (!body.name) return json(res, 422, { detail: 'name required' });
1170 + const a = { id: id('alr'), name: body.name, company: body.company ?? null, condition: body.condition ?? {}, channel: body.channel === 'webhook' ? 'webhook' : 'web', target: body.target ?? null, created_at: new Date().toISOString(), status: 'active' };
1171 + al.push(a);
1172 + return json(res, 201, a);
1173 + }
1174 + if (path === '/alerts/deliveries') {
1175 + const items = al.slice(0, 5).flatMap((a) => events.filter((e) => e.status === 'active' && (!a.company || e.company.slug === a.company) && (!a.condition.event_types?.length || a.condition.event_types.includes(e.event_type))).slice(0, 4).map((e) => ({ id: id('dlv'), alert_id: a.id, alert_name: a.name, event_id: e.id, event: e, channel: a.channel, status: 'delivered', delivered_at: e.detected_at })));
1176 + return json(res, 200, { items: items.slice(0, clamp(Number(q.get('limit') ?? 50), 1, 200)) }, { 'cache-control': 'no-store' });
1177 + }
1178 + if (seg[0] === 'alerts' && seg[1] && req.method === 'DELETE') {
1179 + alerts.set(key, al.filter((a) => a.id !== seg[1]));
1180 + return json(res, 200, { ok: true });
1181 + }
1182 + }
1183 +
1184 + // ---- exports & docs
1185 + if (seg[0] === 'export') {
1186 + const [name, fmt] = (seg[1] ?? '').split('.');
1187 + let rows = [];
1188 + if (name === 'events') rows = filterEvents(events.filter((e) => e.status === 'active'), q).slice(0, clamp(Number(q.get('limit') ?? 10000), 1, 10000)).map(({ sources, payload, entities, company, ...e }) => ({ ...e, company_slug: company.slug, company: company.display_name, tags: e.tags.join('|') }));
1189 + else if (name === 'companies') rows = companies.filter((c) => (!q.get('country') || c.country === q.get('country').toUpperCase()) && (!q.get('industry') || c.industries.includes(q.get('industry')))).map((c) => ({ ...pub(c), industries: c.industries.join('|'), metrics: undefined, counts: undefined, sparkline: undefined, activity_score: c.metrics.activity_score, sensors: c.counts.sensors, events: c.counts.events }));
1190 + else if (name === 'jobs') rows = companies.filter((c) => !q.get('company') || c.slug === q.get('company')).flatMap((c) => c._jobs.map((j) => ({ ...j, company: c.slug })));
1191 + else return notFound(res);
1192 + if (fmt === 'csv') {
1193 + res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': `attachment; filename="${name}.csv"` });
1194 + return res.end(csv(rows));
1195 + }
1196 + if (fmt === 'ndjson') {
1197 + res.writeHead(200, { 'content-type': 'application/x-ndjson' });
1198 + return res.end(rows.map((r) => JSON.stringify(r)).join('\n'));
1199 + }
1200 + return json(res, 200, { items: rows });
1201 + }
1202 + if (path === '/sitemap') {
1203 + const kind = q.get('kind') ?? 'companies';
1204 + const page = Number(q.get('page') ?? 0);
1205 + const items = kind === 'companies' ? companies.filter((c) => c.counts.events > 2).map((c) => ({ slug: c.slug, updated_at: c.last_event_at })) : kind === 'industries' ? industryRows().map((i) => ({ slug: i.slug, updated_at: null })) : countryRows().map((c) => ({ slug: c.code.toLowerCase(), updated_at: null }));
1206 + const per = 5000;
1207 + return json(res, 200, { items: items.slice(page * per, (page + 1) * per), pages: Math.max(1, Math.ceil(items.length / per)) });
1208 + }
1209 + if (path === '/methodology') {
1210 + return json(res, 200, {
1211 + metrics: [
1212 + { metric: 'activity_score', formula_version: 'v1.0', description: 'Coverage-normalised rate of meaningful changes and structured events across a company’s monitored surfaces over 30 days, weighted by surface importance, scaled 0–100 against the population baseline.', inputs: ['meaningful_changes_30d', 'events_30d', 'surface_weights', 'sensors_active', 'population_baseline'] },
1213 + { metric: 'hiring_momentum_30d', formula_version: 'v1.0', description: 'Percentage change in publicly listed open roles over 30 days, adjusted for listings that disappeared and re-appeared within 48 h.', inputs: ['jobs_open_t0', 'jobs_open_t1', 'jobs_new', 'jobs_removed'] },
1214 + { metric: 'product_velocity', formula_version: 'v1.0', description: 'Rate of product, documentation, changelog and API events per 90 days, normalised by the number of product-related sensors.', inputs: ['product_events_90d', 'developer_events_90d', 'sensors_product'] },
1215 + { metric: 'ai_adoption', formula_version: 'v1.1', description: 'Observable public AI signals only: AI-tagged listings share, AI product pages, AI mentions in documentation and first-party communications. Never claims internal usage.', inputs: ['ai_jobs_share', 'ai_products', 'ai_docs_mentions', 'ai_news_mentions'] },
1216 + { metric: 'geo_expansion', formula_version: 'v1.0', description: 'New countries and cities appearing on locations and careers surfaces over 90 days.', inputs: ['new_countries_90d', 'new_cities_90d', 'jobs_new_countries'] },
1217 + { metric: 'developer_momentum', formula_version: 'v1.0', description: 'Documentation, changelog and API surface change rate; public repository activity where available.', inputs: ['docs_changes_90d', 'changelog_entries_90d', 'api_events_90d'] },
1218 + { metric: 'corporate_change_index', formula_version: 'v1.0', description: '0.25 hiring + 0.20 product + 0.15 geographic + 0.15 leadership + 0.10 developer + 0.10 communication + 0.05 pricing; weights are provisional.', inputs: ['hiring_momentum_30d', 'product_velocity', 'geo_expansion', 'leadership_activity', 'developer_momentum', 'communication_activity', 'pricing_activity'] },
1219 + { metric: 'anomaly_score', formula_version: 'v1.0', description: 'Deviation of the last 7 days from the company’s own 90-day baseline of changes and events (z-score mapped to 0–100).', inputs: ['baseline_changes_per_week', 'changes_7d', 'events_7d'] },
1220 + { metric: 'historical_coverage', formula_version: 'v1.0', description: 'Historical Completeness Score: share of days since onboarding with at least one successful observation per active surface, penalised for failed periods.', inputs: ['days_observed', 'days_since_onboarding', 'sensor_uptime', 'failed_periods'] },
1221 + ],
1222 + significance_bands: [
1223 + { label: 'noise', min: 0, max: 0.2 },
1224 + { label: 'minor', min: 0.2, max: 0.4 },
1225 + { label: 'meaningful', min: 0.4, max: 0.65 },
1226 + { label: 'major', min: 0.65, max: 0.85 },
1227 + { label: 'critical', min: 0.85, max: 1 },
1228 + ],
1229 + event_types: [...new Set(EVENT_TEMPLATES.map((t) => t[0]))],
1230 + confidence_labels: { VERIFIED: 'Confirmed by two independent first-party surfaces or a structured feed.', HIGH_CONFIDENCE: 'Deterministic extraction from a structured surface, single source.', LIKELY: 'Deterministic extraction from unstructured HTML with a stable block identity.', INFERRED: 'Interpretation produced by an enrichment model from a deterministic change.', LOW_CONFIDENCE: 'Weak or partially corroborated signal; kept for transparency.' },
1231 + });
1232 + }
1233 +
1234 + // ---- admin
1235 + if (seg[0] === 'admin') {
1236 + if (!admin || String(admin) !== adminToken) return json(res, 401, { detail: 'invalid admin token' });
1237 + const sub = seg[1];
1238 + if (sub === 'overview') {
1239 + const byS = {};
1240 + const byT = {};
1241 + for (const s of sensors.values()) {
1242 + byS[s.status] = (byS[s.status] ?? 0) + 1;
1243 + byT[s.tier] = (byT[s.tier] ?? 0) + 1;
1244 + }
1245 + const failures = {};
1246 + for (const f of FAILURE_CLASSES) failures[f] = ri(0, 60);
1247 + return json(res, 200, { companies_by_status: { ACTIVE: companies.filter((c) => c.status === 'ACTIVE').length, POSSIBLY_INACTIVE: companies.filter((c) => c.status !== 'ACTIVE').length }, sensors_by_status: byS, sensors_by_tier: byT, queue: { pending: ri(120, 900), running: ri(8, 32), dead: ri(0, 14), oldest_pending_s: ri(5, 600) }, llm: { pending: ri(0, 40), done_today: ri(80, 400), failed_today: ri(0, 6), budget_left: r1(rnd() * 100) }, failures_24h_by_class: failures, fetch_rate_1h: ri(15000, 26000), change_rate_1h: ri(600, 1400), meaningful_rate_1h: ri(120, 400), storage: stats().archive, workers: ['M2U64-w1', 'M2U64-w2', 'M2U64-w3', 'M2U64-browser'].map((name) => ({ name, last_seen_at: iso(Date.now() - ri(1, 60) * 1000), inflight: ri(0, 8) })), cost_today: { fetch: r1(rnd() * 3), browser: r1(rnd() * 2), llm: r1(rnd() * 6) } });
1248 + }
1249 + if (sub === 'connectors') return json(res, 200, { items: CONNECTORS.map(([cid, name, version, category]) => ({ id: cid, name, version, category, enabled: cid !== 'lever_connector', sensors_active: [...sensors.values()].filter((s) => s.connector_id === cid && s.status === 'active').length, sensors_failing: [...sensors.values()].filter((s) => s.connector_id === cid && s.status === 'failing').length, success_rate_24h: r1(90 + rnd() * 10), avg_latency_ms: ri(200, 2400), change_rate_24h: r1(rnd() * 12), errors_24h: ri(0, 40), last_run_at: iso(Date.now() - ri(1, 400) * 1000) })) });
1250 + if (sub === 'sensors' && !seg[2]) {
1251 + let list = [...sensors.values()];
1252 + const g = (k) => q.get(k);
1253 + if (g('status')) list = list.filter((s) => s.status === g('status'));
1254 + if (g('domain')) list = list.filter((s) => s.domain.includes(g('domain')));
1255 + if (g('connector')) list = list.filter((s) => s.connector_id === g('connector'));
1256 + if (g('company')) list = list.filter((s) => companies.find((c) => c.id === s.company_id)?.slug === g('company'));
1257 + const f = g('filter');
1258 + if (f === 'healthy') list = list.filter((s) => s.status === 'active' && s.consecutive_failures === 0);
1259 + if (f === 'failing') list = list.filter((s) => s.status === 'failing');
1260 + if (f === 'stale') list = list.filter((s) => new Date(s.last_success_at).getTime() < NOW - 2 * DAY);
1261 + if (f === 'blocked') list = list.filter((s) => s.last_failure_class === 'BOT_CHALLENGE' || s.last_status === 403);
1262 + if (f === 'redirected') list = list.filter((s) => s.last_failure_class === 'REDIRECT');
1263 + if (f === 'low_quality') list = list.filter((s) => s.quality_score < 55);
1264 + if (f === 'high_activity') list = list.filter((s) => s.change_count > 8);
1265 + const page = paginate(list, q, 50);
1266 + page.items = page.items.map((s) => ({ ...pubSensor(s), company: ref(companies.find((c) => c.id === s.company_id)) }));
1267 + return json(res, 200, page);
1268 + }
1269 + if (sub === 'sensors' && seg[2] && seg[3] && req.method === 'POST') {
1270 + const s = sensors.get(seg[2]);
1271 + if (!s) return notFound(res, 'sensor not found');
1272 + const body = await readBody(req);
1273 + const action = seg[3];
1274 + if (action === 'pause') s.status = 'paused';
1275 + if (action === 'resume') s.status = 'active';
1276 + if (action === 'retire') s.status = 'retired';
1277 + if (action === 'retry' || action === 'run_now') {
1278 + s.status = 'active';
1279 + s.consecutive_failures = 0;
1280 + s.next_run_at = new Date().toISOString();
1281 + }
1282 + if (action === 'set_interval' && body.interval_s) s.current_interval_s = Number(body.interval_s);
1283 + if (action === 'set_connector' && body.connector_id) s.connector_id = body.connector_id;
1284 + return json(res, 200, { ok: true, sensor: pubSensor(s), action });
1285 + }
1286 + if (sub === 'companies' && !seg[2]) {
1287 + if (req.method === 'POST') {
1288 + const body = await readBody(req);
1289 + if (!body.website) return json(res, 422, { detail: 'website required' });
1290 + const domain = String(body.website).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
1291 + const c = buildCompany([body.display_name || domain.split('.')[0], domain, (body.country || 'US').toUpperCase(), 'Unknown', 0, 0, body.industries?.length ? body.industries : ['technology'], false, null, null, null, null, 30, null], companies.length);
1292 + c.onboarding_status = 'pending';
1293 + return json(res, 201, pub(c));
1294 + }
1295 + let list = [...companies];
1296 + if (q.get('onboarding_status')) list = list.filter((c) => c.onboarding_status === q.get('onboarding_status'));
1297 + const page = paginate(list, q, 50);
1298 + page.items = page.items.map(pub);
1299 + return json(res, 200, page);
1300 + }
1301 + if (sub === 'companies' && seg[2] && seg[3] === 'rediscover') return json(res, 200, { ok: true, queued: true });
1302 + if (sub === 'failures') {
1303 + const items = Array.from({ length: 120 }, () => {
1304 + const s = pick([...sensors.values()]);
1305 + return { id: id('fail'), sensor_id: s.id, company: ref(companies.find((c) => c.id === s.company_id)), domain: s.domain, failure_class: pick(FAILURE_CLASSES), status_code: pick([0, 403, 404, 429, 500, 503]), message: pick(['connect timeout after 20 s', 'challenge page detected (cf-mitigated)', 'schema validation failed: jobs[3].title missing', 'redirected to /careers-new (301)', 'rate limited by domain governor', 'dns: NXDOMAIN']), occurred_at: iso(Date.now() - ri(1, 1440) * 60_000), retry_at: iso(Date.now() + ri(5, 600) * 60_000) };
1306 + }).sort((a, b) => b.occurred_at.localeCompare(a.occurred_at));
1307 + return json(res, 200, paginate(q.get('class') ? items.filter((i) => i.failure_class === q.get('class')) : items, q, 50));
1308 + }
1309 + if (sub === 'queue' && !seg[2]) {
1310 + const items = Array.from({ length: 80 }, () => ({ id: id('job'), kind: pick(['fetch', 'fetch', 'fetch', 'discover', 'enrich', 'metrics', 'daily']), status: pick(['pending', 'pending', 'running', 'done', 'dead']), priority: ri(1, 100), attempts: ri(0, 4), scheduled_at: iso(Date.now() - ri(0, 3600) * 1000), started_at: chance(0.5) ? iso(Date.now() - ri(0, 600) * 1000) : null, finished_at: null, worker: chance(0.5) ? pick(['M2U64-w1', 'M2U64-w2', 'M2U64-w3']) : null, ref: pick([...sensors.keys()]), error: chance(0.1) ? 'TIMEOUT' : null }));
1311 + const filtered = items.filter((i) => (!q.get('kind') || i.kind === q.get('kind')) && (!q.get('status') || i.status === q.get('status')));
1312 + const counts = {};
1313 + for (const i of items) counts[i.status] = (counts[i.status] ?? 0) + 1;
1314 + return json(res, 200, { items: filtered, counts });
1315 + }
1316 + if (sub === 'queue' && seg[2] === 'requeue-dead') return json(res, 200, { ok: true, requeued: ri(0, 14) });
1317 + if (sub === 'llm') {
1318 + const items = Array.from({ length: 60 }, () => ({ id: id('llm'), kind: pick(['classify', 'summarize', 'extract_event', 'industry_tag']), status: pick(['done', 'done', 'done', 'pending', 'failed']), model: pick(['qwen3-4b-instruct-2507-4bit', 'qwen3.6-35b-a3b-4bit']), prompt_version: pick(['event-classifier/v3', 'event-summary/v2']), change_id: pick([...changes.keys()]), event_id: chance(0.7) ? pick(events).id : null, tokens_in: ri(400, 6000), tokens_out: ri(50, 600), cost_estimate: r1(rnd() * 0.02 * 100) / 100, created_at: iso(Date.now() - ri(0, 1440) * 60_000), finished_at: chance(0.8) ? iso(Date.now() - ri(0, 1000) * 60_000) : null, error: chance(0.1) ? 'schema validation failed' : null }));
1319 + return json(res, 200, paginate(q.get('status') ? items.filter((i) => i.status === q.get('status')) : items, q, 50));
1320 + }
1321 + if (sub === 'reviews' && !seg[2]) {
1322 + const items = Array.from({ length: 24 }, () => {
1323 + const e = pick(events);
1324 + return { id: id('rev'), kind: pick(['major_event', 'low_confidence_extraction', 'company_merge', 'sensor_migration', 'legal_sensitive']), status: chance(0.8) ? 'open' : 'resolved', subject: e.title, ref_id: e.id, company: e.company, reason: pick(['importance ≥ 0.9', 'confidence < 0.55', 'two companies share a domain', 'URL moved; content identity uncertain', 'mentions litigation']), created_at: iso(Date.now() - ri(1, 5000) * 60_000), resolved_at: null, resolution: null };
1325 + });
1326 + return json(res, 200, { items: q.get('status') ? items.filter((i) => i.status === q.get('status')) : items });
1327 + }
1328 + if (sub === 'reviews' && seg[2] && req.method === 'POST') return json(res, 200, { ok: true, id: seg[2], ...(await readBody(req)) });
1329 + if (sub === 'events' && seg[2] && seg[3] && req.method === 'POST') {
1330 + const e = eventsById.get(seg[2]);
1331 + if (!e) return notFound(res, 'event not found');
1332 + e.status = seg[3] === 'retract' ? 'retracted' : 'active';
1333 + return json(res, 200, { ok: true, status: e.status });
1334 + }
1335 + if (sub === 'quality') return json(res, 200, { coverage: { companies_active_pct: r1(92 + rnd() * 6), sensors_active_pct: r1(84 + rnd() * 8) }, freshness: { sensors_checked_24h_pct: r1(88 + rnd() * 10), stale: ri(20, 140) }, duplicate_rate: r1(rnd() * 4), event_confidence_avg: r1(0.7 + rnd() * 0.2), unknown_surfaces: ri(30, 200), failed_sensors: [...sensors.values()].filter((s) => s.status === 'failing').length, calibration: { correct: ri(300, 600), duplicate: ri(5, 40), noise: ri(10, 60), misclassified: ri(3, 30) } });
1336 + if (sub === 'costs') {
1337 + const days = clamp(Number(q.get('days') ?? 30), 1, 90);
1338 + const items = [];
1339 + for (let i = days - 1; i >= 0; i--) for (const [dimension, key] of [['fetch', 'http'], ['fetch', 'browser'], ['llm', 'qwen3.6-35b'], ['storage', 'objects']]) items.push({ day: day(NOW - i * DAY), dimension, key, units: ri(1000, 30000), cost_estimate: r1(rnd() * 8 * 100) / 100 });
1340 + return json(res, 200, { items, per_1000_companies: r1(rnd() * 40 + 10), per_million_observations: r1(rnd() * 6 + 1), per_meaningful_event: r1(rnd() * 0.05 * 1000) / 1000 });
1341 + }
1342 + if (sub === 'cache' && seg[2] === 'clear') return json(res, 200, { ok: true, cleared: ri(4, 40) });
1343 + }
1344 + return notFound(res);
1345 + } catch (e) {
1346 + console.error(e);
1347 + return json(res, 500, { detail: `mock error: ${e.message}` });
1348 + }
1349 +});
1350 +
1351 +server.listen(PORT, '127.0.0.1', () => {
1352 + console.log(`Company Atlas mock API on http://127.0.0.1:${PORT}/api/v1 — ${companies.length} companies, ${sensors.size} sensors, ${events.length} events, ${changes.size} changes. Admin token: ${adminToken}`);
1353 +});
added apps/web/qa/screens.mjs +199 −0
@@ -0,0 +1,199 @@
1 +/**
2 + * QA sweep: every route at 390×844 (mobile) and 1440×900 (desktop), dark and light — HTTP status, console errors,
3 + * failed requests (404/5xx), horizontal overflow, small tap targets (mobile), screenshot into qa/screens/ (git-ignored).
4 + * Also checks that the homepage counters match GET /api/v1/stats and that the SSE feed prepends a row.
5 + * Run: node qa/screens.mjs [BASE_URL] [API_URL] (defaults http://localhost:8370, http://127.0.0.1:8371)
6 + * Env: MOBILE_ONLY=1 · WIDTHS=360 · THEMES=dark · ONLY=/company/stripe
7 + */
8 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
9 +import { mkdirSync } from 'node:fs';
10 +
11 +const BASE = process.argv[2] ?? 'http://localhost:8370';
12 +const API = process.argv[3] ?? 'http://127.0.0.1:8371';
13 +const OUT = new URL('./screens/', import.meta.url).pathname;
14 +mkdirSync(OUT, { recursive: true });
15 +
16 +async function j(path) {
17 + const r = await fetch(`${API}/api/v1${path}`);
18 + if (!r.ok) throw new Error(`${path} → ${r.status}`);
19 + return r.json();
20 +}
21 +// discover real ids from the API so provenance routes render
22 +let ids = { event: null, sensor: null, snapshot: null, snapshot2: null, change: null };
23 +try {
24 + const live = await j('/live?limit=5');
25 + const items = Array.isArray(live) ? live : live.items;
26 + const e = items.find((x) => x.change_id && x.sensor_id) ?? items[0];
27 + ids.event = e?.id ?? null;
28 + ids.change = e?.change_id ?? null;
29 + ids.sensor = e?.sensor_id ?? null;
30 + if (ids.sensor) {
31 + const snaps = await j(`/sensors/${ids.sensor}/snapshots?limit=2`);
32 + ids.snapshot = snaps.items?.[0]?.id ?? null;
33 + ids.snapshot2 = snaps.items?.[1]?.id ?? null;
34 + }
35 +} catch (e) {
36 + console.log(`WARN could not discover ids from the API: ${e.message}`);
37 +}
38 +
39 +const PAGES = [
40 + '/',
41 + '/live',
42 + '/live?event_type=PRICING&min_importance=0.5',
43 + '/companies',
44 + '/companies?country=CA&sort=hiring',
45 + '/company/stripe',
46 + '/company/stripe?tab=timeline',
47 + '/company/stripe?tab=timeline&filter=jobs',
48 + '/company/stripe?tab=signals',
49 + '/company/stripe?tab=jobs',
50 + '/company/stripe?tab=jobs&ai=1&status=removed',
51 + '/company/stripe?tab=products',
52 + '/company/stripe?tab=pricing',
53 + '/company/stripe?tab=locations',
54 + '/company/stripe?tab=leadership',
55 + '/company/stripe?tab=sources',
56 + '/company/stripe?tab=history',
57 + '/company/compare?companies=stripe,adyen,block',
58 + '/company/compare',
59 + '/events',
60 + '/events?event_type=HIRING&min_confidence=0.7&sort=importance',
61 + ids.event ? `/events/${ids.event}` : null,
62 + ids.change ? `/change/${ids.change}` : null,
63 + ids.sensor ? `/sensor/${ids.sensor}` : null,
64 + ids.snapshot ? `/snapshot/${ids.snapshot}` : null,
65 + ids.snapshot && ids.snapshot2 ? `/snapshot/${ids.snapshot2}/diff/${ids.snapshot}` : null,
66 + '/rankings',
67 + '/rankings?kind=hiring_decline&window=7d',
68 + '/industry',
69 + '/industry/fintech',
70 + '/country',
71 + '/country/ca',
72 + '/search?q=stripe',
73 + '/search?q=companies%20hiring%20AI%20engineers%20in%20Canada',
74 + '/watchlist',
75 + '/system',
76 + '/about',
77 + '/methodology',
78 + '/api',
79 + '/bot',
80 + '/admin',
81 + '/company/does-not-exist',
82 + '/does-not-exist',
83 +].filter(Boolean);
84 +const ONLY = process.env.ONLY;
85 +const pages = ONLY ? PAGES.filter((p) => p.startsWith(ONLY)) : PAGES;
86 +const WIDTHS = process.env.WIDTHS ? process.env.WIDTHS.split(',').map(Number) : process.env.MOBILE_ONLY ? [390] : [390, 1440];
87 +const THEMES = process.env.THEMES ? process.env.THEMES.split(',') : ['dark', 'light'];
88 +const fmt0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
89 +
90 +const browser = await chromium.launch();
91 +let failures = 0;
92 +const filterErrors = (errors, expected) => errors.filter((e) => !/favicon|the server responded with a status of 404 \(Not Found\)/.test(e) || expected !== 404).filter((e) => !/Encountered a script tag while rendering React component/.test(e));
93 +
94 +/** Interactive controls smaller than 44 px on mobile (text links in prose/tables/lists are exempt). */
95 +const smallTargets = () => {
96 + const root = document.querySelector('[data-palette], [role="dialog"]') ?? document;
97 + return [...root.querySelectorAll('button, [role="button"], input, select, a[href]')]
98 + .filter((el) => {
99 + const r = el.getBoundingClientRect();
100 + if (r.width === 0 || r.height === 0) return false;
101 + const cs = getComputedStyle(el);
102 + if (cs.visibility === 'hidden' || cs.display === 'none') return false;
103 + if (el.closest('.sr-only, p, dd, dt, .prose-atlas, td, th, .kv, .data-table, footer, li, summary, h1, h2, h3, table, svg')) return false;
104 + if (el.classList.contains('chip-btn')) return false; // 32 px chips are intentionally compact and spaced
105 + if (el.tagName === 'A') {
106 + const inNav = !!el.closest('nav, [role="menu"], [role="listbox"]');
107 + if (!inNav && el.textContent.trim().length > 0) return false;
108 + }
109 + return Math.min(r.width, r.height) < 40;
110 + })
111 + .map((el) => `${el.tagName.toLowerCase()}.${[...el.classList].slice(0, 2).join('.')} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 22)}" ${Math.round(el.getBoundingClientRect().width)}×${Math.round(el.getBoundingClientRect().height)}`);
112 +};
113 +
114 +for (const theme of THEMES) {
115 + for (const width of WIDTHS) {
116 + const mobile = width < 768;
117 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
118 + await ctx.addInitScript((t) => localStorage.setItem('ca-theme', t), theme);
119 + const page = await ctx.newPage();
120 + for (const path of pages) {
121 + const errors = [];
122 + const badReqs = [];
123 + const onErr = (e) => errors.push(String(e));
124 + const onCon = (m) => {
125 + if (m.type() === 'error') errors.push(m.text());
126 + };
127 + const onResp = (r) => {
128 + const s = r.status();
129 + if ((s === 404 || s >= 500) && !/favicon/.test(r.url())) badReqs.push(`${s} ${r.url().replace(BASE, '')}`);
130 + };
131 + page.on('pageerror', onErr);
132 + page.on('console', onCon);
133 + page.on('response', onResp);
134 + const t0 = Date.now();
135 + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
136 + await page.waitForTimeout(700);
137 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
138 + const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);
139 + const small = mobile ? await page.evaluate(smallTargets).catch(() => []) : [];
140 + const expected = /does-not-exist/.test(path) ? 404 : 200;
141 + const status = res.status();
142 + const filtered = filterErrors(errors, expected);
143 + const bad = badReqs.filter((b) => !(expected === 404 && b.startsWith('404 ' + path)) && !/\/api\/v1\/(watchlist|alerts)/.test(b));
144 + const ok = status === expected && overflow <= 0 && filtered.length === 0 && bad.length === 0 && applied === theme && small.length === 0;
145 + if (!ok) failures++;
146 + console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} bad=${bad.length} small=${small.length} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}${bad.length ? ' :: ' + bad.slice(0, 2).join(' | ') : ''}${small.length ? ' :: ' + small.slice(0, 3).join(' | ') : ''}`);
147 + await page.screenshot({ path: `${OUT}${theme}-${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);
148 + page.off('pageerror', onErr);
149 + page.off('console', onCon);
150 + page.off('response', onResp);
151 + }
152 + await ctx.close();
153 + }
154 +}
155 +
156 +// counters must come from /api/v1/stats
157 +if (!ONLY) {
158 + try {
159 + const t0 = Date.now();
160 + const before = await j('/stats');
161 + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
162 + const page = await ctx.newPage();
163 + await page.goto(BASE + '/', { waitUntil: 'networkidle' });
164 + await page.waitForTimeout(6000); // client refresh of /stats (1.5 s) + count-up; also long enough to measure the ingestion rate
165 + const text = await page.evaluate(() => document.querySelector('[data-live-counters]')?.innerText ?? '');
166 + const after = await j('/stats');
167 + const nums = [...text.matchAll(/\d{1,3}(?:,\d{3})*/g)].map((m) => Number(m[0].replace(/,/g, '')));
168 + // the page is ISR-cached (60 s) while the API keeps ingesting: accept values within 120 s of observed growth
169 + const elapsed = Math.max(1, (Date.now() - t0) / 1000);
170 + for (const k of ['companies', 'sensors', 'observations', 'changes', 'events']) {
171 + const rate = Math.max(0, after[k] - before[k]) / elapsed;
172 + const lo = Math.min(before[k], after[k]) - Math.ceil(rate * 120) - 5;
173 + const hi = Math.max(before[k], after[k]) + 5;
174 + const hit = nums.some((n) => n >= lo && n <= hi);
175 + if (!hit) failures++;
176 + console.log(`${hit ? 'OK ' : 'FAIL'} counter ${k}: page shows a value in [${fmt0.format(lo)}, ${fmt0.format(hi)}]`);
177 + }
178 + // SSE: a new row should appear within ~12 s on /live
179 + await page.goto(BASE + '/live', { waitUntil: 'networkidle' });
180 + const first = await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'));
181 + await page.mouse.move(5, 5);
182 + let changed = false;
183 + for (let i = 0; i < 16 && !changed; i++) {
184 + await page.waitForTimeout(1000);
185 + changed = (await page.evaluate(() => document.querySelector('[data-live-feed] [data-event-id]')?.getAttribute('data-event-id'))) !== first;
186 + }
187 + if (!changed) failures++;
188 + const rows = await page.evaluate(() => document.querySelectorAll('[data-live-feed] [data-event-id]').length);
189 + const status = await page.evaluate(() => document.querySelector('[data-live-feed] .dot + span')?.textContent ?? '?');
190 + console.log(`${changed ? 'OK ' : 'FAIL'} SSE live feed prepended a new event (rows=${rows}, status=${status})`);
191 + await ctx.close();
192 + } catch (e) {
193 + console.log(`SKIP counters/SSE check: ${e.message}`);
194 + }
195 +}
196 +
197 +await browser.close();
198 +console.log(failures ? `\n${failures} failure(s)` : '\nall checks OK');
199 +process.exit(failures ? 1 : 0);
added apps/web/src/app/about/page.tsx +68 −0
@@ -0,0 +1,68 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ConfidenceBadge } from '@/components/ui/badges';
4 +import { Container, PageHeader } from '@/components/ui/section';
5 +import { ALT_TAGLINE, CONTACT_EMAIL, DESCRIPTION, routes } from '@/lib/site';
6 +
7 +export const metadata: Metadata = { title: 'About', description: `${DESCRIPTION} Positioning, methodology summary and careful-language policy.` };
8 +
9 +export default function AboutPage() {
10 + return (
11 + <Container>
12 + <PageHeader eyebrow="About" title="A continuously updated corporate observation network" lede={DESCRIPTION} />
13 + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2">
14 + <p>{ALT_TAGLINE}</p>
15 + <h2>What Company Atlas is — and is not</h2>
16 + <p>
17 + Company Atlas attaches persistent public-web sensors to companies. Each sensor watches one public surface — careers page, pricing page, newsroom, leadership page, locations, documentation, changelog, legal pages, investor relations, feeds — on an adaptive schedule. Observations become normalised snapshots; snapshots produce block-level changes; meaningful changes become structured events; events feed metrics, rankings and indices. Every day the network runs, the historical record becomes harder to reproduce. That accumulated history is the product.
18 + </p>
19 + <ul>
20 + <li>
21 + <strong>Not a directory.</strong> A directory stores the current state; Company Atlas stores state(t0), state(t1), state(t2)… and treats the present as the latest historical state.
22 + </li>
23 + <li>
24 + <strong>Not a news aggregator.</strong> News is one surface among dozens; most events are detected from pages that never publish a press release — a pricing table, a job board, a leadership grid.
25 + </li>
26 + <li>
27 + <strong>Not a web archive.</strong> Pages are normalised into semantic blocks and structured fields; the goal is measurement of corporate change, not preservation of HTML.
28 + </li>
29 + <li>
30 + <strong>Not a financial terminal.</strong> No market data, no filings analysis; investor relations pages are monitored as public surfaces only and are not a substitute for regulated disclosures.
31 + </li>
32 + </ul>
33 + <h2>Methodology in one paragraph</h2>
34 + <p>
35 + Fetching is deterministic and polite (robots.txt, per-domain rate limits, no authentication or challenge bypass, no private data). Noise — dates, tracking parameters, rotating testimonials, cookie banners — is normalised away before hashing. Block-level diffs are scored for significance (noise · minor · meaningful · major · critical) from text delta, semantic similarity, page importance, affected structured entities, novelty and cross-source corroboration. Only meaningful changes are turned into events; language models are an enrichment step on top of deterministic detection, budgeted and versioned. Metrics (Activity Score, Hiring Momentum, Product Velocity, AI Adoption, Geographic Expansion, Developer Momentum, Corporate Change Index) are reproducible: each value carries a formula version, its inputs and a computation time. Full detail on the{' '}
36 + <Link href={routes.methodology()}>methodology page</Link>.
37 + </p>
38 + <h2>Careful-language policy</h2>
39 + <p>The platform reports what was observed on public pages and labels everything inferred. In practice:</p>
40 + <ul>
41 + <li>“72 monitored listings are no longer visible” — never “the company laid off 72 employees”.</li>
42 + <li>“No longer listed on the monitored leadership page” — never “fired” or “left”.</li>
43 + <li>“Product no longer listed in the public catalog” — never “discontinued” without a first-party statement.</li>
44 + <li>Predictions are explicitly probabilistic: “Possible launch preparation signal — confidence 63 %”.</li>
45 + <li>
46 + Every inferred value carries a confidence label:{' '}
47 + <span className="inline-flex flex-wrap gap-1 align-middle">
48 + {['VERIFIED', 'HIGH_CONFIDENCE', 'LIKELY', 'INFERRED', 'LOW_CONFIDENCE'].map((l) => (
49 + <ConfidenceBadge key={l} label={l} />
50 + ))}
51 + </span>
52 + </li>
53 + <li>When evidence is missing we say so: “No monitored evidence available yet.” / “Last successfully checked 3 days ago.” Numbers are never fabricated to fill a gap.</li>
54 + </ul>
55 + <h2>Provenance and corrections</h2>
56 + <p>
57 + Every user-facing event links to the public page where it was detected, with the detection time of each corroborating source. If a source page disappears, the observation is preserved and the event says so. Errors are corrected by retraction with an audit trail, never by silent deletion. Companies can ask for a surface to be excluded through robots.txt or by writing to <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>; see <Link href={routes.bot()}>the crawler page</Link>.
58 + </p>
59 + <h2>Privacy</h2>
60 + <p>No consumer profiling. People appear only in their public professional context on the company’s own pages (name, title, first/last seen). Nothing behind a login is ever collected.</p>
61 + <h2>Who builds it</h2>
62 + <p>
63 + Company Atlas is designed and operated by Simon-Pierre Boucher and hosted on the MacLustr cluster. Contact: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>. Public API and exports: <Link href={routes.api()}>/api</Link>.
64 + </p>
65 + </div>
66 + </Container>
67 + );
68 +}
added apps/web/src/app/admin/[module]/page.tsx +36 −0
@@ -0,0 +1,36 @@
1 +import type { Metadata } from 'next';
2 +import { notFound } from 'next/navigation';
3 +import { CompaniesModule, ConnectorsModule, CostsModule, FailuresModule, LlmModule, OverviewModule, QualityModule, QueueModule, ReviewsModule, SensorsModule } from '@/components/admin/modules';
4 +import { ADMIN_MODULES } from '@/lib/admin-modules';
5 +import { flat, str, type SP } from '@/lib/params';
6 +
7 +export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } };
8 +export const dynamic = 'force-dynamic';
9 +
10 +export default async function AdminModulePage({ params, searchParams }: { params: Promise<{ module: string }>; searchParams: Promise<SP> }) {
11 + const { module } = await params;
12 + const sp = await searchParams;
13 + if (!ADMIN_MODULES.some((m) => m.id === module)) notFound();
14 + switch (module) {
15 + case 'connectors':
16 + return <ConnectorsModule />;
17 + case 'sensors':
18 + return <SensorsModule initial={Object.fromEntries(Object.entries(flat(sp)).filter(([, v]) => v !== undefined)) as Record<string, string>} />;
19 + case 'companies':
20 + return <CompaniesModule />;
21 + case 'failures':
22 + return <FailuresModule initialClass={str(sp.class)} />;
23 + case 'queue':
24 + return <QueueModule />;
25 + case 'llm':
26 + return <LlmModule />;
27 + case 'reviews':
28 + return <ReviewsModule />;
29 + case 'quality':
30 + return <QualityModule />;
31 + case 'costs':
32 + return <CostsModule />;
33 + default:
34 + return <OverviewModule />;
35 + }
36 +}
added apps/web/src/app/admin/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import type { Metadata } from 'next';
2 +import { OverviewModule } from '@/components/admin/modules';
3 +
4 +export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } };
5 +
6 +export default function AdminPage() {
7 + return <OverviewModule />;
8 +}
added apps/web/src/app/api/page.tsx +147 −0
@@ -0,0 +1,147 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Container, Note, PageHeader, Section } from '@/components/ui/section';
4 +import { CONTACT_EMAIL, PUBLIC_API_BASE, routes } from '@/lib/site';
5 +
6 +export const metadata: Metadata = { title: 'API & data', description: 'Public JSON API for Company Atlas: companies, events, timelines, metrics, rankings, industries, countries, search, SSE streaming and exports.' };
7 +
8 +const GROUPS: { title: string; rows: [string, string, string][] }[] = [
9 + {
10 + title: 'Platform',
11 + rows: [
12 + ['GET', '/stats', 'Global counters: companies, sensors, observations, changes, events, dataset age (cached 60 s)'],
13 + ['GET', '/stats/history?days=90', 'Daily global aggregates and the activity index'],
14 + ['GET', '/system', 'Public aggregate health'],
15 + ['GET', '/pulse', 'Homepage aggregate (live, movers, hiring, launches, pricing, AI, industries, countries, trending, index, map)'],
16 + ['GET', '/live?limit=50&since=&event_type=&min_importance=', 'Latest active events (no cache)'],
17 + ['GET', '/live/stream', 'Server-sent events: `event: event` with an Event JSON, `event: heartbeat` every 20 s; `?since=` supported'],
18 + ],
19 + },
20 + {
21 + title: 'Companies',
22 + rows: [
23 + ['GET', '/companies?q=&country=&industry=&tier=&public=&sort=&sparkline=1', 'Paginated CompanyCard rows (`page`, `per_page` ≤ 200)'],
24 + ['GET', '/companies/{slug}', 'Detail: aliases, domains, relationships, metrics with confidence, coverage, signals, sparklines'],
25 + ['GET', '/companies/{slug}/events', 'Events with filters `event_type, event_subtype, since, until, min_importance, surface`'],
26 + ['GET', '/companies/{slug}/timeline?filter=all|products|jobs|pricing|leadership|locations|legal|news|developer', 'Grouped by day'],
27 + ['GET', '/companies/{slug}/metrics?days=90', 'Current metrics + series'],
28 + ['GET', '/companies/{slug}/jobs?status=open|removed|all&ai=1', 'Listings + summary (`meta.summary`)'],
29 + ['GET', '/companies/{slug}/people · /products · /pricing · /locations · /news', 'Reconciled entities with first/last seen'],
30 + ['GET', '/companies/{slug}/sensors · /history · /similar', 'Sensors, historical page viewer index, similar companies'],
31 + ['GET', '/companies/compare?companies=stripe,adyen,block', 'Side-by-side metrics, series, events, jobs, locations (2–6)'],
32 + ],
33 + },
34 + {
35 + title: 'Provenance',
36 + rows: [
37 + ['GET', '/sensors/{id} · /snapshots · /changes', 'Sensor with company and latest snapshot; its versions and changes'],
38 + ['GET', '/snapshots/{id}', 'Normalised text, semantic blocks, extracted fields (≤ 200 kB)'],
39 + ['GET', '/snapshots/{id}/diff/{other_id}', 'Block-level diff computed on demand'],
40 + ['GET', '/changes/{id}', 'Change with diff, structured delta and derived events'],
41 + ['GET', '/events/{id}', 'Event with every corroborating source and detection time'],
42 + ],
43 + },
44 + {
45 + title: 'Events, rankings, atlases',
46 + rows: [
47 + ['GET', '/events?event_type=&country=&industry=&since=&min_importance=&min_confidence=&q=&origin=&sort=', 'Paginated events'],
48 + ['GET', '/events/types · /events/summary?days=7&group=type|industry|country', 'Taxonomy counts and deltas'],
49 + ['GET', '/rankings?kind=most_active|hiring_growth|…&window=24h|7d|30d|90d|1y&country=&industry=', 'Ranked CompanyCards with value and delta'],
50 + ['GET', '/industries · /industries/{slug} · /countries · /countries/{code}', 'Living indices per industry and country'],
51 + ['GET', '/signals?scope=company|industry|country|global · /trends?window=7d · /map?metric=events_30d · /index', 'Signals, trending terms, map buckets, Global Corporate Activity Index'],
52 + ],
53 + },
54 + {
55 + title: 'Search, watchlists, exports',
56 + rows: [
57 + ['GET', '/search?q=&types=companies,events,… · /search/suggest?q= · /ask?q=', 'Grouped search, fast suggestions, natural-language routing'],
58 + ['GET/POST/DELETE', '/watchlist · /watchlist/{slug} · /alerts · /alerts/{id} · /alerts/deliveries', 'Owner-token endpoints (`X-CA-Owner-Token`, ≥ 24 chars, stored hashed)'],
59 + ['GET', '/export/events.{json,ndjson,csv}?since=&event_type=&country=&limit=10000', 'Streamed export'],
60 + ['GET', '/export/companies.{json,ndjson,csv}?country=&industry= · /export/jobs.ndjson?company=&since=', 'Streamed exports'],
61 + ['GET', '/sitemap?kind=companies|industries|countries&page= · /methodology', 'Indexable slugs; metric definitions'],
62 + ],
63 + },
64 +];
65 +
66 +export default function ApiPage() {
67 + const base = PUBLIC_API_BASE;
68 + return (
69 + <Container>
70 + <PageHeader eyebrow="Developers" title="API & data" lede="JSON over HTTPS, UTC ISO-8601 timestamps, immutable ids (co_…, sen_…, evt_…) and slugs for public URLs. Public GET endpoints need no key. Errors are `{ detail }` with 4xx/5xx." />
71 + <div className="prose-atlas max-w-3xl text-sm text-ink-2">
72 + <p>
73 + Base URL: <code>{base}</code>. Pagination: <code>?page=1&per_page=25</code> (max 200) → <code>{'{ items, page, per_page, total, pages }'}</code>. Unknown parameters are ignored. Interactive OpenAPI docs: <a href={`${base}/docs`}>{base.replace(/^https?:\/\//, '')}/docs</a>.
74 + </p>
75 + <h2>Quick start</h2>
76 + <pre>
77 + <code>{`# latest structured events
78 +curl -s "${base}/live?limit=5" | jq '.items[] | {company: .company.display_name, type: .event_type, title, confidence_label, source_url}'
79 +
80 +# a company with its metrics, coverage and signals
81 +curl -s "${base}/companies/stripe" | jq '{display_name, metrics, counts, coverage}'
82 +
83 +# pricing events in Canada since September, most important first
84 +curl -s "${base}/events?event_type=PRICING&country=CA&since=2026-09-01T00:00:00Z&sort=importance"
85 +
86 +# rankings: fastest hiring growth over 30 days in fintech
87 +curl -s "${base}/rankings?kind=hiring_growth&window=30d&industry=fintech"
88 +
89 +# stream (SSE)
90 +curl -N "${base}/live/stream"
91 +
92 +# export as NDJSON
93 +curl -s "${base}/export/events.ndjson?event_type=LEADERSHIP&limit=1000" > leadership.ndjson`}</code>
94 + </pre>
95 + <h2>Watchlists without an account</h2>
96 + <pre>
97 + <code>{`TOKEN=$(uuidgen | tr -d -)$(uuidgen | tr -d -)
98 +curl -s -X POST "${base}/watchlist" -H "X-CA-Owner-Token: $TOKEN" -H "content-type: application/json" -d '{"company":"stripe"}'
99 +curl -s "${base}/watchlist" -H "X-CA-Owner-Token: $TOKEN"
100 +curl -s -X POST "${base}/alerts" -H "X-CA-Owner-Token: $TOKEN" -H "content-type: application/json" \\
101 + -d '{"name":"Stripe pricing","company":"stripe","condition":{"event_types":["PRICING"],"min_importance":0.5},"channel":"webhook","target":"https://example.com/hook"}'`}</code>
102 + </pre>
103 + </div>
104 + {GROUPS.map((g) => (
105 + <Section key={g.title} eyebrow="Endpoints" title={g.title}>
106 + <div className="table-scroll">
107 + <table className="data-table compact">
108 + <thead>
109 + <tr>
110 + <th>Method</th>
111 + <th>Path</th>
112 + <th>Notes</th>
113 + </tr>
114 + </thead>
115 + <tbody>
116 + {g.rows.map(([m, p, n]) => (
117 + <tr key={p}>
118 + <td className="mono text-xs text-ink-3">{m}</td>
119 + <td className="mono wrap text-xs text-ink">{p}</td>
120 + <td className="wrap text-ink-2">{n}</td>
121 + </tr>
122 + ))}
123 + </tbody>
124 + </table>
125 + </div>
126 + </Section>
127 + ))}
128 + <Section eyebrow="Conventions" title="Rate limits, caching, licensing">
129 + <div className="prose-atlas max-w-3xl text-sm text-ink-2">
130 + <ul>
131 + <li>Anonymous: 120 requests / minute per IP; bursts above that return 429 with a Retry-After header. Higher limits, webhooks and bulk datasets on request by email to <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a> (free for research).</li>
132 + <li>
133 + Public aggregates (<code>/pulse</code>, <code>/stats</code>, <code>/rankings</code>, <code>/industries</code>, <code>/countries</code>) are cached 60 s and served with <code>Cache-Control: public, max-age=60</code>; <code>/live*</code>, owner and admin routes are <code>no-store</code>.
134 + </li>
135 + <li>Metrics are 0–100 floats rounded to one decimal, except hiring momentum (percentage, may be negative) and open jobs (integer). A metric without inputs is omitted or null — never zero.</li>
136 + <li>Company lookups accept slug or id; slugs may change, ids never do. Unknown → 404 <code>{'{ "detail": "company not found" }'}</code>.</li>
137 + <li>Redistribution: derived data (events, metrics, entity facts, metadata) may be reused with attribution to Company Atlas and a link to the event page; raw page content is not redistributed.</li>
138 + <li>
139 + Careful language is part of the contract: <code>status: no_longer_listed</code>, <code>confidence_label</code> and <code>origin</code> (deterministic · llm · hybrid · backfill) ship with every event — see <Link href={routes.methodology()}>methodology</Link>.
140 + </li>
141 + </ul>
142 + </div>
143 + <Note className="mt-4">Shapes are documented in the repository (`docs/API.md`) and mirrored by the web client’s TypeScript types.</Note>
144 + </Section>
145 + </Container>
146 + );
147 +}
added apps/web/src/app/apple-icon.tsx +17 −0
@@ -0,0 +1,17 @@
1 +import { ImageResponse } from 'next/og';
2 +import { MARK_DARK, MarkImg } from '@/components/brand/mark';
3 +
4 +/** 180×180 PNG: the atlas plate on the dark canvas (iOS applies its own corner mask). */
5 +export const size = { width: 180, height: 180 };
6 +export const contentType = 'image/png';
7 +
8 +export default function AppleIcon() {
9 + return new ImageResponse(
10 + (
11 + <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
12 + <MarkImg px={180} colors={{ ...MARK_DARK, plate: '#11151c' }} radius={0} />
13 + </div>
14 + ),
15 + { ...size },
16 + );
17 +}
added apps/web/src/app/bot/page.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Container, PageHeader } from '@/components/ui/section';
4 +import { BOT_UA, CONTACT_EMAIL, routes, SITE_URL } from '@/lib/site';
5 +
6 +export const metadata: Metadata = { title: 'CompanyAtlasBot — our crawler', description: 'Identity of the Company Atlas crawler, what it collects, how it behaves, and how to opt out with robots.txt.' };
7 +
8 +export default function BotPage() {
9 + return (
10 + <Container>
11 + <PageHeader eyebrow="Crawler" title={BOT_UA} lede="Company Atlas observes publicly accessible corporate web pages with a clearly identified, polite crawler. This page is linked from its User-Agent string." />
12 + <div className="prose-atlas max-w-3xl text-[15px] leading-relaxed text-ink-2">
13 + <h2>Identity</h2>
14 + <pre>
15 + <code>{`User-Agent: ${BOT_UA}/0.1 (+${SITE_URL}/bot; ${CONTACT_EMAIL})`}</code>
16 + </pre>
17 + <p>
18 + Requests originate from the MacLustr infrastructure operated by Simon-Pierre Boucher (Québec, Canada). Contact for any question, rate concern or removal request: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.
19 + </p>
20 + <h2>What we collect</h2>
21 + <ul>
22 + <li>Publicly accessible pages of company websites: homepage, about, careers and job boards, newsroom, blog and feeds, products, pricing, leadership, locations, documentation, changelogs, legal/terms/privacy, security, investor relations, partners, customers, sitemaps.</li>
23 + <li>Public structured data on those pages (JSON-LD, microdata, RSS/Atom) and public JSON endpoints that the page itself loads.</li>
24 + <li>Text and structure only. Images, videos, fonts, binaries and tracking scripts are not downloaded by default.</li>
25 + </ul>
26 + <h2>What we never do</h2>
27 + <ul>
28 + <li>Never log in, never bypass authentication, paywalls, CAPTCHAs or bot challenges; a challenge page is recorded as a failure and the sensor backs off.</li>
29 + <li>Never collect non-public or personal consumer data. People appear only in their public professional context on the company’s own pages.</li>
30 + <li>Never access private networks, localhost or infrastructure endpoints.</li>
31 + </ul>
32 + <h2>How it behaves</h2>
33 + <ul>
34 + <li>
35 + Honours <code>robots.txt</code> (including <code>Crawl-delay</code>) for <code>{BOT_UA}</code> and <code>*</code>.
36 + </li>
37 + <li>Per-domain concurrency of 1–2 and per-domain rate limits; adaptive schedules from every 5–15 minutes for very active pages to every 3–7 days for stable ones; conditional requests (ETag / Last-Modified) whenever the server supports them.</li>
38 + <li>Retry budgets with exponential backoff; sustained 429/403 responses pause the domain.</li>
39 + <li>Redirect and size caps; no endless pagination, calendars or faceted crawling.</li>
40 + </ul>
41 + <h2>Opting out</h2>
42 + <p>Add to your robots.txt:</p>
43 + <pre>
44 + <code>{`User-agent: ${BOT_UA}
45 +Disallow: /`}</code>
46 + </pre>
47 + <p>
48 + Or disallow only specific paths. Changes are picked up within 24 hours. To also remove already collected observations of your pages, write to <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a> from a company address; we suppress the surfaces and mark the history as unavailable rather than pretending it never existed.
49 + </p>
50 + <h2>Attribution</h2>
51 + <p>
52 + Every event on <Link href={routes.home()}>Company Atlas</Link> links to the public page it was detected on. See also the <Link href={routes.about()}>about page</Link> and the <Link href={routes.api()}>API terms</Link>.
53 + </p>
54 + </div>
55 + </Container>
56 + );
57 +}
added apps/web/src/app/change/[id]/page.tsx +100 −0
@@ -0,0 +1,100 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { DiffViewer } from '@/components/events/diff-viewer';
5 +import { EventList } from '@/components/events/event-row';
6 +import { SignificanceBadge } from '@/components/ui/badges';
7 +import { KV, Row } from '@/components/ui/key-value';
8 +import { Container, Note } from '@/components/ui/section';
9 +import { api, ApiError } from '@/lib/api';
10 +import { fmtDateTime, fmtInt, fmtPct } from '@/lib/format';
11 +import { routes, SURFACE_LABELS } from '@/lib/site';
12 +
13 +export const revalidate = 600;
14 +export const metadata: Metadata = { title: 'Change', robots: { index: false } };
15 +
16 +export default async function ChangePage({ params }: { params: Promise<{ id: string }> }) {
17 + const { id } = await params;
18 + let ch;
19 + try {
20 + ch = await api.change(id);
21 + } catch (e) {
22 + if (e instanceof ApiError && e.notFound) notFound();
23 + throw e;
24 + }
25 + return (
26 + <Container>
27 + <div className="pb-4 pt-6 md:pt-10">
28 + <p className="eyebrow flex flex-wrap items-center gap-2">
29 + Change · {SURFACE_LABELS[ch.surface] ?? ch.surface}
30 + <SignificanceBadge value={ch.significance} />
31 + </p>
32 + <h1 className="display mt-2 text-[24px] md:text-[32px]">
33 + {ch.company ? (
34 + <Link href={routes.company(ch.company.slug)} className="hover:text-accent">
35 + {ch.company.display_name}
36 + </Link>
37 + ) : (
38 + 'Monitored page'
39 + )}{' '}
40 + <span className="text-ink-3">·</span> {SURFACE_LABELS[ch.surface] ?? ch.surface} changed
41 + </h1>
42 + <p className="mt-2 text-sm text-ink-2">
43 + Detected {fmtDateTime(ch.detected_at)} · kind <span className="mono">{ch.kind}</span> · text delta {fmtPct(ch.text_delta_ratio, 1, true)}
44 + {ch.similarity !== null ? ` · similarity ${fmtPct(ch.similarity, 1, true)}` : ''}
45 + </p>
46 + </div>
47 + <div className="grid gap-8 lg:grid-cols-12">
48 + <div className="lg:col-span-8">
49 + <DiffViewer diff={ch.diff} significance={ch.significance} />
50 + </div>
51 + <aside className="space-y-6 lg:col-span-4">
52 + <KV>
53 + <Row k="Sensor">
54 + <Link href={routes.sensor(ch.sensor_id)} className="link mono text-xs break-all">
55 + {ch.sensor_id}
56 + </Link>
57 + </Row>
58 + <Row k="Before">
59 + {ch.snapshot_before ? (
60 + <Link href={routes.snapshot(ch.snapshot_before)} className="link mono text-xs break-all">
61 + {ch.snapshot_before}
62 + </Link>
63 + ) : (
64 + <span className="text-ink-3">first observation</span>
65 + )}
66 + </Row>
67 + <Row k="After">
68 + <Link href={routes.snapshot(ch.snapshot_after)} className="link mono text-xs break-all">
69 + {ch.snapshot_after}
70 + </Link>
71 + </Row>
72 + {ch.snapshot_before && (
73 + <Row k="Full diff">
74 + <Link href={routes.snapshotDiff(ch.snapshot_before, ch.snapshot_after)} className="link text-xs">
75 + Snapshot diff viewer →
76 + </Link>
77 + </Row>
78 + )}
79 + <Row k="Blocks">
80 + <span className="tnum">
81 + +{fmtInt(ch.blocks_added)} · −{fmtInt(ch.blocks_removed)} · ~{fmtInt(ch.blocks_modified)}
82 + </span>
83 + </Row>
84 + </KV>
85 + {Object.keys(ch.structured_delta ?? {}).length > 0 && (
86 + <div>
87 + <p className="eyebrow mb-1">Structured delta</p>
88 + <pre className="overflow-x-auto border border-rule bg-surface-2 p-3 text-xs">{JSON.stringify(ch.structured_delta, null, 2)}</pre>
89 + </div>
90 + )}
91 + <div>
92 + <p className="eyebrow mb-1">Events derived from this change</p>
93 + <EventList events={ch.events ?? []} variant="table" showCompany={false} emptyLabel="No structured event was derived from this change (below the significance threshold or awaiting processing)." />
94 + </div>
95 + <Note>Raw observations, normalised snapshots, diffs and events are stored separately; this diff can be recomputed from the two snapshots without re-fetching the page.</Note>
96 + </aside>
97 + </div>
98 + </Container>
99 + );
100 +}
added apps/web/src/app/companies/page.tsx +93 −0
@@ -0,0 +1,93 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { CompanyTable } from '@/components/company/company-table';
4 +import { Pagination, withParams } from '@/components/ui/pagination';
5 +import { Container, PageHeader, Unavailable } from '@/components/ui/section';
6 +import { api, safe } from '@/lib/api';
7 +import { cn } from '@/lib/cn';
8 +import { fmtInt } from '@/lib/format';
9 +import { flat, int, str, type SP } from '@/lib/params';
10 +
11 +export const metadata: Metadata = { title: 'Companies', description: 'Directory of monitored companies with activity, hiring momentum, AI adoption, sensors and event counts.' };
12 +export const revalidate = 120;
13 +
14 +const SORTS = [
15 + ['activity', 'Activity'],
16 + ['events', 'Events'],
17 + ['hiring', 'Hiring'],
18 + ['recent', 'Most recent event'],
19 + ['importance', 'Importance'],
20 + ['name', 'Name'],
21 +] as const;
22 +
23 +export default async function CompaniesPage({ searchParams }: { searchParams: Promise<SP> }) {
24 + const sp = await searchParams;
25 + const cur = flat(sp);
26 + const page = int(sp.page, 1);
27 + const query = { q: str(sp.q), country: str(sp.country), industry: str(sp.industry), tier: str(sp.tier), public: str(sp.public), has_events: str(sp.has_events), sort: str(sp.sort) ?? 'activity', page, per_page: 50, sparkline: 1 };
28 + const [data, countries, industries] = await Promise.all([safe(api.companies(query)), safe(api.countries()), safe(api.industries())]);
29 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/companies', cur, patch);
30 + return (
31 + <Container wide>
32 + <PageHeader eyebrow="Directory" title="Companies" lede={data ? `${fmtInt(data.total)} monitored companies. Each row links to the profile with its sensors, timeline and historical page versions.` : 'Monitored companies.'} />
33 + <form method="get" action="/companies" className="mb-4 grid gap-2 sm:grid-cols-[minmax(0,2fr)_repeat(3,minmax(0,1fr))_auto]">
34 + <input type="search" name="q" defaultValue={str(sp.q)} placeholder="Search name, domain, alias…" className="field" aria-label="Search companies" />
35 + <select name="country" defaultValue={str(sp.country) ?? ''} className="field" aria-label="Country">
36 + <option value="">All countries</option>
37 + {(countries?.items ?? []).map((c) => (
38 + <option key={c.code} value={c.code}>
39 + {c.name} ({c.companies})
40 + </option>
41 + ))}
42 + </select>
43 + <select name="industry" defaultValue={str(sp.industry) ?? ''} className="field" aria-label="Industry">
44 + <option value="">All industries</option>
45 + {(industries?.items ?? []).map((i) => (
46 + <option key={i.slug} value={i.slug}>
47 + {i.name} ({i.companies})
48 + </option>
49 + ))}
50 + </select>
51 + <select name="tier" defaultValue={str(sp.tier) ?? ''} className="field" aria-label="Tier">
52 + <option value="">All tiers</option>
53 + {[1, 2, 3, 4].map((t) => (
54 + <option key={t} value={t}>
55 + Tier {t}
56 + </option>
57 + ))}
58 + </select>
59 + <input type="hidden" name="sort" value={query.sort} />
60 + <button type="submit" className="btn btn-primary">
61 + Filter
62 + </button>
63 + </form>
64 + <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
65 + <span className="text-ink-3">Sort</span>
66 + {SORTS.map(([id, label]) => (
67 + <Link key={id} href={href({ sort: id === 'activity' ? undefined : id })} className="chip-btn" data-on={query.sort === id}>
68 + {label}
69 + </Link>
70 + ))}
71 + <Link href={href({ public: str(sp.public) === '1' ? undefined : '1' })} className={cn('chip-btn ml-2')} data-on={str(sp.public) === '1'}>
72 + Public companies
73 + </Link>
74 + <Link href={href({ has_events: str(sp.has_events) === '1' ? undefined : '1' })} className="chip-btn" data-on={str(sp.has_events) === '1'}>
75 + With events
76 + </Link>
77 + {(str(sp.q) || str(sp.country) || str(sp.industry) || str(sp.tier) || str(sp.public) || str(sp.has_events)) && (
78 + <Link href="/companies" className="text-ink-3 underline-offset-2 hover:underline">
79 + Clear
80 + </Link>
81 + )}
82 + </div>
83 + {!data ? (
84 + <Unavailable what="The directory" />
85 + ) : (
86 + <>
87 + <CompanyTable items={data.items} />
88 + <Pagination total={data.total} page={data.page} pages={data.pages} perPage={data.per_page} makeHref={(p) => href({ page: p > 1 ? p : undefined })} className="mt-4" />
89 + </>
90 + )}
91 + </Container>
92 + );
93 +}
added apps/web/src/app/company/[slug]/layout.tsx +34 −0
@@ -0,0 +1,34 @@
1 +import type { Metadata } from 'next';
2 +import { notFound } from 'next/navigation';
3 +import { api, ApiError } from '@/lib/api';
4 +import { countryName } from '@/lib/countries';
5 +import { SITE_NAME } from '@/lib/site';
6 +
7 +/** Existence check lives in the segment layout (Next 16: a root `loading.tsx` would soft-404). */
8 +async function load(slug: string) {
9 + try {
10 + return await api.company(slug);
11 + } catch (e) {
12 + if (e instanceof ApiError && e.notFound) notFound();
13 + return null;
14 + }
15 +}
16 +
17 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
18 + const { slug } = await params;
19 + const c = await load(slug);
20 + if (!c) return { title: 'Company' };
21 + const desc = `${c.display_name} (${c.canonical_domain}${c.country ? `, ${countryName(c.country)}` : ''}): ${c.counts.sensors} active sensors, ${c.counts.events} structured events, ${c.counts.changes} historical changes. ${c.description ?? ''}`.trim();
22 + return {
23 + title: `${c.display_name} — sensors, timeline and history`,
24 + description: desc,
25 + alternates: { canonical: `/company/${c.slug}` },
26 + openGraph: { title: `${c.display_name} | ${SITE_NAME}`, description: desc, type: 'profile' },
27 + };
28 +}
29 +
30 +export default async function CompanyLayout({ params, children }: { params: Promise<{ slug: string }>; children: React.ReactNode }) {
31 + const { slug } = await params;
32 + await load(slug);
33 + return <>{children}</>;
34 +}
added apps/web/src/app/company/[slug]/page.tsx +212 −0
@@ -0,0 +1,212 @@
1 +import { GitCompareArrows } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Suspense } from 'react';
5 +import { Bars } from '@/components/charts/bars';
6 +import { LineChart } from '@/components/charts/line-chart';
7 +import { WorldMap } from '@/components/charts/world-map';
8 +import { CompanyHeader, DensityStrip } from '@/components/company/company-header';
9 +import { CompanyMiniList } from '@/components/company/company-table';
10 +import { MetricTiles } from '@/components/company/metric-tiles';
11 +import { ConfidenceLegend, HistoryPanel, JobsPanel, LocationsPanel, PeoplePanel, PricingPanel, ProductsPanel, SensorsTable, SignalsPanel, TimelinePanel } from '@/components/company/panels';
12 +import { EventList } from '@/components/events/event-row';
13 +import { Chip } from '@/components/ui/badges';
14 +import { KV, Row } from '@/components/ui/key-value';
15 +import { Container, Empty, Note } from '@/components/ui/section';
16 +import { TabPanel, Tabs } from '@/components/ui/tabs';
17 +import { api, ApiError, safe } from '@/lib/api';
18 +import { fmtDate, fmtPct, humanize } from '@/lib/format';
19 +import { bool, str, type SP } from '@/lib/params';
20 +import { routes, SURFACE_LABELS } from '@/lib/site';
21 +import type { CompanyDetail, MapBucket } from '@/lib/types';
22 +
23 +export const revalidate = 120;
24 +
25 +const TABS = [
26 + { id: 'overview', label: 'Overview' },
27 + { id: 'timeline', label: 'Timeline' },
28 + { id: 'signals', label: 'Signals' },
29 + { id: 'jobs', label: 'Jobs' },
30 + { id: 'products', label: 'Products' },
31 + { id: 'pricing', label: 'Pricing' },
32 + { id: 'locations', label: 'Locations' },
33 + { id: 'leadership', label: 'Leadership' },
34 + { id: 'sources', label: 'Sources' },
35 + { id: 'history', label: 'History' },
36 +];
37 +
38 +export default async function CompanyPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {
39 + const { slug } = await params;
40 + const sp = await searchParams;
41 + let c: CompanyDetail;
42 + try {
43 + c = await api.company(slug);
44 + } catch (e) {
45 + if (e instanceof ApiError && e.notFound) notFound();
46 + throw e;
47 + }
48 + const requested = str(sp.tab) ?? 'overview';
49 + const tab = TABS.some((t) => t.id === requested) ? requested : 'overview';
50 + const tabs = TABS.map((t) => ({ ...t, count: t.id === 'jobs' ? c.counts.jobs_open : t.id === 'sources' ? c.counts.sensors : t.id === 'signals' ? c.signals?.length || undefined : undefined }));
51 +
52 + return (
53 + <Container wide>
54 + <CompanyHeader c={c} />
55 + <MetricTiles c={c} />
56 + <DensityStrip c={c} />
57 + <Suspense>
58 + <Tabs tabs={tabs} className="mt-4" sticky>
59 + <TabPanel id={tab}>
60 + {tab === 'overview' && <Overview c={c} />}
61 + {tab === 'timeline' && <TimelinePanel slug={c.slug} data={await safe(api.companyTimeline(c.slug, str(sp.filter) ?? 'all'))} filter={str(sp.filter) ?? 'all'} />}
62 + {tab === 'signals' && <SignalsPanel signals={c.signals ?? []} />}
63 + {tab === 'jobs' && <JobsPanel slug={c.slug} data={await safe(api.companyJobs(c.slug, { status: str(sp.status) ?? 'open', ai: bool(sp.ai) ? 1 : undefined, per_page: 50, page: str(sp.page) }))} status={str(sp.status) ?? 'open'} ai={bool(sp.ai)} />}
64 + {tab === 'products' && <ProductsPanel data={await safe(api.companyProducts(c.slug))} />}
65 + {tab === 'pricing' && <PricingPanel data={await safe(api.companyPricing(c.slug))} />}
66 + {tab === 'locations' && <Locations slug={c.slug} />}
67 + {tab === 'leadership' && <PeoplePanel data={await safe(api.companyPeople(c.slug))} />}
68 + {tab === 'sources' && <Sources c={c} />}
69 + {tab === 'history' && <HistoryPanel data={await safe(api.companyHistory(c.slug))} />}
70 + </TabPanel>
71 + </Tabs>
72 + </Suspense>
73 + <div className="mt-10 flex flex-wrap items-center justify-between gap-3 border-t border-rule pt-5">
74 + <p className="text-sm text-ink-2">Compare {c.display_name} with peers on activity, hiring, product velocity, AI adoption, locations and events.</p>
75 + <Link href={routes.compare([c.slug])} className="btn">
76 + <GitCompareArrows className="size-4" aria-hidden /> Compare companies
77 + </Link>
78 + </div>
79 + </Container>
80 + );
81 +}
82 +
83 +async function Overview({ c }: { c: CompanyDetail }) {
84 + const [events, similar, metrics] = await Promise.all([safe(api.companyEvents(c.slug, { per_page: 10 })), safe(api.companySimilar(c.slug, 8)), safe(api.companyMetrics(c.slug, 90))]);
85 + const surfaces = Object.entries(c.sensors_by_surface ?? {}).sort((a, b) => b[1] - a[1]);
86 + const series = metrics?.series ?? {};
87 + const lines = (['activity_score', 'product_velocity', 'ai_adoption'] as const).filter((k) => (series[k]?.length ?? 0) > 1).map((k) => ({ id: k, label: humanize(k), points: series[k]!.map((p) => ({ day: p.day, value: p.value })) }));
88 + return (
89 + <div className="grid gap-8 lg:grid-cols-12">
90 + <div className="space-y-8 lg:col-span-8">
91 + <section>
92 + <p className="eyebrow mb-2">Latest structured events</p>
93 + {events ? <EventList events={events.items} variant="table" showCompany={false} emptyLabel="No structured events detected for this company yet — sensors are attached and observing." /> : <Empty title="Events temporarily unavailable." />}
94 + {events && events.total > 10 && (
95 + <p className="mt-2 text-sm">
96 + <Link href={routes.company(c.slug, 'timeline')} className="link">
97 + Full timeline ({events.total} events) →
98 + </Link>
99 + </p>
100 + )}
101 + </section>
102 + <section>
103 + <p className="eyebrow mb-2">90-day metric series</p>
104 + {lines.length ? <LineChart series={lines} height={200} yZero /> : <Empty compact title="Not enough history for a series yet." />}
105 + </section>
106 + {c.signals?.length > 0 && (
107 + <section>
108 + <p className="eyebrow mb-2">Active signals</p>
109 + <SignalsPanel signals={c.signals.slice(0, 3)} />
110 + </section>
111 + )}
112 + </div>
113 + <aside className="space-y-8 lg:col-span-4">
114 + <section>
115 + <p className="eyebrow mb-2">Identity</p>
116 + <KV>
117 + <Row k="Legal name">{c.legal_name ?? '—'}</Row>
118 + <Row k="Domains">
119 + {c.domains?.length ? (
120 + <span className="mono text-xs">
121 + {c.domains.map((d) => (
122 + <span key={d.domain} className="block">
123 + {d.domain} <span className="text-ink-3">· {d.kind}</span>
124 + </span>
125 + ))}
126 + </span>
127 + ) : (
128 + <span className="mono text-xs">{c.canonical_domain}</span>
129 + )}
130 + </Row>
131 + {c.aliases?.length > 0 && <Row k="Aliases">{c.aliases.join(', ')}</Row>}
132 + <Row k="Status">
133 + <Chip>{c.status.toLowerCase().replace(/_/g, ' ')}</Chip>
134 + </Row>
135 + <Row k="Importance">
136 + <span className="tnum">
137 + {c.importance} · tier {c.tier}
138 + </span>
139 + </Row>
140 + <Row k="Coverage">
141 + <span className="tnum">
142 + {fmtPct(c.coverage?.historical_coverage, 0)} · {c.coverage?.days_observed ?? '—'} days observed
143 + {c.coverage?.first_observed_at ? ` · since ${fmtDate(c.coverage.first_observed_at)}` : ''}
144 + </span>
145 + </Row>
146 + {c.coverage?.sensor_uptime !== null && c.coverage?.sensor_uptime !== undefined && (
147 + <Row k="Sensor uptime">
148 + <span className="tnum">{fmtPct(c.coverage.sensor_uptime, 1)}</span>
149 + </Row>
150 + )}
151 + </KV>
152 + </section>
153 + {c.relationships?.length > 0 && (
154 + <section>
155 + <p className="eyebrow mb-2">Relationships</p>
156 + <ul className="divide-y divide-rule border-y border-rule text-sm">
157 + {c.relationships.map((r, i) => (
158 + <li key={i} className="flex flex-wrap items-center gap-2 py-1.5">
159 + <Chip>{r.kind.toLowerCase().replace(/_/g, ' ')}</Chip>
160 + {r.company ? (
161 + <Link href={routes.company(r.company.slug)} className="hover:text-accent">
162 + {r.company.display_name}
163 + </Link>
164 + ) : (
165 + <span>{r.to_name ?? '—'}</span>
166 + )}
167 + <span className="ml-auto text-[11px] text-ink-3">
168 + {r.valid_from ? `since ${fmtDate(r.valid_from)} · ` : ''}confidence {Math.round(r.confidence * 100)} %
169 + </span>
170 + </li>
171 + ))}
172 + </ul>
173 + </section>
174 + )}
175 + <section>
176 + <p className="eyebrow mb-2">Sensors by surface</p>
177 + {surfaces.length ? <Bars dense rows={surfaces.map(([s, n]) => ({ key: s, label: SURFACE_LABELS[s] ?? s, value: n }))} /> : <Empty compact />}
178 + <p className="mt-1 text-xs">
179 + <Link href={routes.company(c.slug, 'sources')} className="link">
180 + All sensors →
181 + </Link>
182 + </p>
183 + </section>
184 + <section>
185 + <p className="eyebrow mb-2">Similar companies</p>
186 + {similar ? <CompanyMiniList items={similar.items} metric="activity_score" label="activity" /> : <Empty compact />}
187 + </section>
188 + <ConfidenceLegend />
189 + </aside>
190 + </div>
191 + );
192 +}
193 +
194 +async function Locations({ slug }: { slug: string }) {
195 + const data = await safe(api.companyLocations(slug));
196 + const buckets: MapBucket[] = (data?.items ?? []).filter((l) => l.status === 'listed' && l.lat !== null && l.lon !== null).map((l) => ({ lat: l.lat as number, lon: l.lon as number, country: l.country ?? '', city: l.city, companies: 1, events_30d: 1, jobs_open: 0, top: [] }));
197 + return <LocationsPanel data={data} map={buckets.length ? <WorldMap buckets={buckets} metric="companies" interactive={false} highlight={[...new Set(buckets.map((b) => b.country))]} /> : <Note>No geocoded locations to draw yet.</Note>} />;
198 +}
199 +
200 +async function Sources({ c }: { c: CompanyDetail }) {
201 + const data = await safe(api.companySensors(c.slug));
202 + if (!data) return <Empty title="Sensors temporarily unavailable." />;
203 + const items = [...data.items].sort((a, b) => (a.status === b.status ? b.event_count - a.event_count : a.status === 'active' ? -1 : 1));
204 + return (
205 + <div className="space-y-3">
206 + <Note>
207 + Every fact on this profile traces back to one of these sensors. A sensor is a deployed connector watching one public URL on a schedule (tier A = 5–15 min … E = 3–7 days). Open a sensor for its observations, snapshots and changes.
208 + </Note>
209 + <SensorsTable items={items} />
210 + </div>
211 + );
212 +}
added apps/web/src/app/company/compare/page.tsx +153 −0
@@ -0,0 +1,153 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { LineChart } from '@/components/charts/line-chart';
4 +import { Sparkline } from '@/components/charts/sparkline';
5 +import { ComparePicker } from '@/components/company/compare-picker';
6 +import { CountryChip, EventTypeBadge } from '@/components/ui/badges';
7 +import { Container, Empty, Note, PageHeader, Section } from '@/components/ui/section';
8 +import { api, safe } from '@/lib/api';
9 +import { cn } from '@/lib/cn';
10 +import { EVENT_TYPES } from '@/lib/event-styles';
11 +import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
12 +import { str, type SP } from '@/lib/params';
13 +import { METRIC_LABELS, routes } from '@/lib/site';
14 +
15 +export const metadata: Metadata = { title: 'Compare companies', description: 'Side-by-side activity, hiring momentum, product velocity, AI adoption, locations and events for 2–6 monitored companies.' };
16 +export const revalidate = 120;
17 +
18 +const METRICS = ['activity_score', 'hiring_momentum_30d', 'product_velocity', 'ai_adoption', 'geo_expansion', 'developer_momentum', 'corporate_change_index', 'open_jobs'] as const;
19 +
20 +export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {
21 + const sp = await searchParams;
22 + const slugs = (str(sp.companies) ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6);
23 + const data = slugs.length >= 2 ? await safe(api.compare(slugs)) : null;
24 + const names: Record<string, string> = {};
25 + if (data) for (const c of data.companies) names[c.slug] = c.display_name;
26 + else if (slugs.length === 1) {
27 + const c = await safe(api.company(slugs[0] as string));
28 + if (c) names[c.slug] = c.display_name;
29 + }
30 + const cols = data?.companies ?? [];
31 + return (
32 + <Container wide>
33 + <PageHeader eyebrow="Comparison" title="Compare companies" lede="Pick two to six companies. Metrics are the latest computed values; series show 90 days of Activity Score; events count structured events detected in the last 30 days." />
34 + <ComparePicker slugs={slugs} names={names} />
35 + {slugs.length < 2 ? (
36 + <Empty className="mt-6" title={slugs.length === 1 ? 'Add at least one more company to compare.' : 'Add two or more companies to start.'}>
37 + Try <Link href={routes.compare(['stripe', 'adyen', 'block'])} className="link">Stripe · Adyen · Block</Link>.
38 + </Empty>
39 + ) : !data ? (
40 + <Empty className="mt-6" title="Comparison unavailable." />
41 + ) : (
42 + <>
43 + <Section eyebrow="Metrics" title="Latest computed values">
44 + <div className="table-scroll">
45 + <table className="data-table">
46 + <thead>
47 + <tr>
48 + <th>Metric</th>
49 + {cols.map((c) => (
50 + <th key={c.slug} className="num">
51 + <Link href={routes.company(c.slug)} className="row-link">
52 + {c.display_name}
53 + </Link>
54 + <span className="block font-normal normal-case tracking-normal text-ink-3">
55 + <CountryChip code={c.country} link={false} /> {c.industry_primary?.replace(/-/g, ' ')}
56 + </span>
57 + </th>
58 + ))}
59 + </tr>
60 + </thead>
61 + <tbody>
62 + {METRICS.map((m) => {
63 + const vals = cols.map((c) => num(data.metrics[m]?.[c.slug] ?? c.metrics[m]));
64 + const best = m === 'hiring_momentum_30d' || m === 'open_jobs' ? Math.max(...vals.map((v) => v ?? -Infinity)) : Math.max(...vals.map((v) => v ?? -Infinity));
65 + return (
66 + <tr key={m}>
67 + <td className="primary">{METRIC_LABELS[m]}</td>
68 + {vals.map((v, i) => (
69 + <td key={cols[i]!.slug} className={cn('num tnum', v !== null && v === best && cols.length > 1 && 'font-semibold text-accent', m === 'hiring_momentum_30d' && v !== null && (v > 0 ? 'text-positive' : v < 0 ? 'text-danger' : ''))}>
70 + {v === null ? '—' : m === 'hiring_momentum_30d' ? fmtPctSigned(v) : m === 'open_jobs' ? fmtInt(v) : fmtScore(v)}
71 + </td>
72 + ))}
73 + </tr>
74 + );
75 + })}
76 + <tr>
77 + <td className="primary">Sensors · events</td>
78 + {cols.map((c) => (
79 + <td key={c.slug} className="num tnum">
80 + {fmtInt(c.counts.sensors)} · {fmtInt(c.counts.events)}
81 + </td>
82 + ))}
83 + </tr>
84 + <tr>
85 + <td className="primary">Jobs (open · AI · new 30 d)</td>
86 + {cols.map((c) => {
87 + const j = data.jobs[c.slug];
88 + return (
89 + <td key={c.slug} className="num tnum">
90 + {j ? `${fmtInt(j.open)} · ${fmtInt(j.ai_open)} · ${fmtInt(j.new_30d)}` : '—'}
91 + </td>
92 + );
93 + })}
94 + </tr>
95 + <tr>
96 + <td className="primary">Listed locations</td>
97 + {cols.map((c) => (
98 + <td key={c.slug} className="num tnum">
99 + {fmtInt(data.locations[c.slug])}
100 + </td>
101 + ))}
102 + </tr>
103 + <tr>
104 + <td className="primary">Activity 30 d</td>
105 + {cols.map((c) => (
106 + <td key={c.slug} className="num">
107 + <Sparkline values={c.sparkline} width={90} height={22} tone="accent" className="ml-auto" />
108 + </td>
109 + ))}
110 + </tr>
111 + </tbody>
112 + </table>
113 + </div>
114 + <Note className="mt-2">Highlighted = highest value in the row. A dash means the metric has no inputs yet for that company — it is omitted, not zero.</Note>
115 + </Section>
116 + <Section eyebrow="Small multiples" title="Activity Score, 90 days">
117 + <LineChart series={cols.filter((c) => data.series[c.slug]?.length).map((c) => ({ id: c.slug, label: c.display_name, points: (data.series[c.slug] ?? []).map((p) => ({ day: p.day, value: p.value })) }))} height={220} yZero />
118 + </Section>
119 + <Section eyebrow="Events by type" title="Structured events, last 30 days">
120 + <div className="table-scroll">
121 + <table className="data-table compact">
122 + <thead>
123 + <tr>
124 + <th>Type</th>
125 + {cols.map((c) => (
126 + <th key={c.slug} className="num">
127 + {c.display_name}
128 + </th>
129 + ))}
130 + </tr>
131 + </thead>
132 + <tbody>
133 + {EVENT_TYPES.filter((t) => cols.some((c) => (data.events_30d[c.slug]?.[t] ?? 0) > 0)).map((t) => (
134 + <tr key={t}>
135 + <td>
136 + <EventTypeBadge type={t} />
137 + </td>
138 + {cols.map((c) => (
139 + <td key={c.slug} className="num tnum">
140 + {data.events_30d[c.slug]?.[t] ? <Link href={`${routes.company(c.slug, 'timeline')}`} className="row-link">{fmtInt(data.events_30d[c.slug]?.[t])}</Link> : <span className="text-ink-3">·</span>}
141 + </td>
142 + ))}
143 + </tr>
144 + ))}
145 + </tbody>
146 + </table>
147 + </div>
148 + </Section>
149 + </>
150 + )}
151 + </Container>
152 + );
153 +}
added apps/web/src/app/country/[code]/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Bars } from '@/components/charts/bars';
5 +import { LineChart } from '@/components/charts/line-chart';
6 +import { WorldMap } from '@/components/charts/world-map';
7 +import { CompanyMiniList, CompanyTable } from '@/components/company/company-table';
8 +import { EventList } from '@/components/events/event-row';
9 +import { CountryChip } from '@/components/ui/badges';
10 +import { Container, Empty, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
11 +import { api, ApiError, safe } from '@/lib/api';
12 +import { fmtInt, fmtPctSigned, fmtScore } from '@/lib/format';
13 +import { routes } from '@/lib/site';
14 +import type { CompanyCard, CountryDetailRaw } from '@/lib/types';
15 +
16 +export const revalidate = 300;
17 +
18 +async function load(code: string): Promise<CountryDetailRaw> {
19 + try {
20 + return await api.country(code.toUpperCase());
21 + } catch (e) {
22 + if (e instanceof ApiError && e.notFound) notFound();
23 + throw e;
24 + }
25 +}
26 +export async function generateMetadata({ params }: { params: Promise<{ code: string }> }): Promise<Metadata> {
27 + const { code } = await params;
28 + const d = await safe(api.country(code.toUpperCase()));
29 + if (!d) return { title: 'Country' };
30 + return { title: `${d.name} — country atlas`, description: `${d.name}: ${Array.isArray(d.companies) ? d.companies.length : d.companies} monitored companies, ${d.events_30d} structured events in 30 days, hiring momentum ${fmtPctSigned(d.hiring_momentum_30d)}.`, alternates: { canonical: `/country/${code.toLowerCase()}` } };
31 +}
32 +
33 +export default async function CountryPage({ params }: { params: Promise<{ code: string }> }) {
34 + const { code } = await params;
35 + const d = await load(code);
36 + const companies: CompanyCard[] = Array.isArray(d.companies) ? d.companies : [];
37 + const count = Array.isArray(d.companies) ? d.companies.length : d.companies;
38 + const map = await safe(api.map('events_30d'));
39 + const buckets = (map?.buckets ?? []).filter((b) => b.country.toUpperCase() === d.code.toUpperCase());
40 + return (
41 + <Container wide>
42 + <PageHeader
43 + eyebrow={
44 + <>
45 + <Link href={routes.countries()} className="hover:text-ink">
46 + Country atlas
47 + </Link>
48 + <span>/</span>
49 + <CountryChip code={d.code} link={false} />
50 + {d.region && <span>{d.region}</span>}
51 + </>
52 + }
53 + title={d.name}
54 + lede={`${fmtInt(count)} monitored companies headquartered in ${d.name}. Activity and hiring are averages over the monitored population.`}
55 + />
56 + <StatGrid cols={5}>
57 + <Stat label="Companies" value={fmtInt(count)} size="sm" />
58 + <Stat label="Events 7 d" value={fmtInt(d.events_7d)} size="sm" />
59 + <Stat label="Events 30 d" value={fmtInt(d.events_30d)} size="sm" />
60 + <Stat label="Activity" value={fmtScore(d.activity_score)} size="sm" />
61 + <Stat label="Hiring 30 d" value={fmtPctSigned(d.hiring_momentum_30d)} size="sm" />
62 + </StatGrid>
63 + <div className="grid gap-8 lg:grid-cols-12">
64 + <Section eyebrow="Activity" title="Country activity, 90 days" className="lg:col-span-8">
65 + {d.series?.length > 1 ? <LineChart series={[{ id: 'activity', label: 'Activity score', points: d.series.map((p) => ({ day: p.day, value: p.value })) }]} height={200} yZero /> : <Empty compact title="Not enough history yet." />}
66 + </Section>
67 + <Section eyebrow="Industry mix" title="Monitored companies by industry" className="lg:col-span-4">
68 + {d.industry_mix?.length ? <Bars dense rows={d.industry_mix.slice(0, 10).map((m) => ({ key: m.industry, label: m.industry.replace(/-/g, ' '), value: m.companies, href: routes.industry(m.industry) }))} /> : <Empty compact />}
69 + </Section>
70 + </div>
71 + <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
72 + <Section eyebrow="Top movers" title="Highest Corporate Change Index">
73 + <CompanyMiniList items={d.movers ?? []} metric="corporate_change_index" label="CCI" sparkline />
74 + </Section>
75 + <Section eyebrow="New entrants" title="Most recently onboarded">
76 + <CompanyMiniList items={d.new_entrants ?? []} metric="activity_score" label="activity" />
77 + </Section>
78 + <Section eyebrow="Map" title="Headquarters clusters">
79 + {buckets.length ? <WorldMap buckets={buckets} highlight={[d.code]} interactive={false} /> : <Empty compact title="No geocoded clusters yet." />}
80 + </Section>
81 + </div>
82 + <Section eyebrow="Companies" title="Most active monitored companies" action={{ href: routes.companies({ country: d.code }), label: 'All in directory' }}>
83 + <CompanyTable items={companies} />
84 + </Section>
85 + <Section eyebrow="Events" title="Latest structured events" action={{ href: routes.events({ country: d.code }), label: 'All events' }}>
86 + <EventList events={d.events ?? []} variant="table" />
87 + <Note className="mt-3">Country attribution uses the company’s headquarters. Expansion events (new offices, new countries) appear under the company’s home country and on the destination country’s timeline where detected.</Note>
88 + </Section>
89 + </Container>
90 + );
91 +}
added apps/web/src/app/country/page.tsx +79 −0
@@ -0,0 +1,79 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { WorldMap } from '@/components/charts/world-map';
4 +import { CountryChip } from '@/components/ui/badges';
5 +import { Container, PageHeader, Unavailable } from '@/components/ui/section';
6 +import { api, safe } from '@/lib/api';
7 +import { cn } from '@/lib/cn';
8 +import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +
11 +export const metadata: Metadata = { title: 'Country atlas', description: 'Activity, hiring, expansion and industry mix of monitored companies by country.' };
12 +export const revalidate = 300;
13 +
14 +export default async function CountriesPage() {
15 + const [data, map] = await Promise.all([safe(api.countries()), safe(api.map('events_30d'))]);
16 + return (
17 + <Container wide>
18 + <PageHeader eyebrow="Country atlas" title="Countries" lede="Monitored companies by headquarters country: activity, structured events, hiring momentum and industry mix. Coverage differs by country — compare trends, not absolute counts." />
19 + {map?.buckets.length ? <WorldMap buckets={map.buckets} className="mb-8" /> : null}
20 + {!data ? (
21 + <Unavailable what="Countries" />
22 + ) : (
23 + <>
24 + <div className="table-scroll hidden md:block">
25 + <table className="data-table">
26 + <thead>
27 + <tr>
28 + <th>Country</th>
29 + <th>Region</th>
30 + <th className="num">Companies</th>
31 + <th className="num">Events 7 d</th>
32 + <th className="num">Events 30 d</th>
33 + <th className="num">Activity</th>
34 + <th className="num">Hiring 30 d</th>
35 + <th>Industry mix</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + {data.items.map((c) => {
40 + const h = num(c.hiring_momentum_30d);
41 + return (
42 + <tr key={c.code}>
43 + <td className="primary">
44 + <Link href={routes.country(c.code)} className="row-link inline-flex items-center gap-2">
45 + <CountryChip code={c.code} link={false} /> {c.name}
46 + </Link>
47 + </td>
48 + <td className="text-ink-2">{c.region ?? '—'}</td>
49 + <td className="num tnum">{fmtInt(c.companies)}</td>
50 + <td className="num tnum">{fmtInt(c.events_7d)}</td>
51 + <td className="num tnum">{fmtInt(c.events_30d)}</td>
52 + <td className="num tnum">{fmtScore(c.activity_score)}</td>
53 + <td className={cn('num tnum', h !== null && (h > 0 ? 'text-positive' : h < 0 ? 'text-danger' : ''))}>{h === null ? '—' : fmtPctSigned(h)}</td>
54 + <td className="text-xs text-ink-2">{c.industry_mix.slice(0, 3).map((m) => `${m.industry.replace(/-/g, ' ')} ${m.companies}`).join(' · ')}</td>
55 + </tr>
56 + );
57 + })}
58 + </tbody>
59 + </table>
60 + </div>
61 + <ul className="divide-y divide-rule border-y border-rule md:hidden">
62 + {data.items.map((c) => (
63 + <li key={c.code}>
64 + <Link href={routes.country(c.code)} className="block py-3">
65 + <p className="flex items-center gap-2 text-[15px] font-medium text-ink">
66 + <CountryChip code={c.code} link={false} /> {c.name}
67 + </p>
68 + <p className="tnum mt-0.5 text-xs text-ink-3">
69 + {fmtInt(c.companies)} companies · {fmtInt(c.events_30d)} events 30 d · activity {fmtScore(c.activity_score)} · hiring {fmtPctSigned(c.hiring_momentum_30d, 0)}
70 + </p>
71 + </Link>
72 + </li>
73 + ))}
74 + </ul>
75 + </>
76 + )}
77 + </Container>
78 + );
79 +}
added apps/web/src/app/error.tsx +33 −0
@@ -0,0 +1,33 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { useEffect } from 'react';
4 +import { Container } from '@/components/ui/section';
5 +
6 +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
7 + useEffect(() => {
8 + // Error shells render from scratch and can lose the prepaint theme attribute.
9 + if (!document.documentElement.getAttribute('data-theme')) {
10 + const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
11 + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
12 + }
13 + }, []);
14 + return (
15 + <Container className="py-20 md:py-28">
16 + <p className="eyebrow">Error</p>
17 + <h1 className="display mt-2 text-3xl md:text-5xl">Something went wrong.</h1>
18 + <p className="mt-4 max-w-xl text-ink-2">The page could not be rendered. The dataset itself is intact — this is usually the API being briefly unavailable.</p>
19 + {error.digest && <p className="mono mt-2 text-xs text-ink-3">ref {error.digest}</p>}
20 + <div className="mt-6 flex gap-2 text-sm">
21 + <button type="button" onClick={reset} className="btn btn-primary">
22 + Try again
23 + </button>
24 + <Link href="/" className="btn">
25 + Home
26 + </Link>
27 + <Link href="/system" className="btn">
28 + System status
29 + </Link>
30 + </div>
31 + </Container>
32 + );
33 +}
added apps/web/src/app/events/[id]/page.tsx +102 −0
@@ -0,0 +1,102 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { EventEvidence } from '@/components/events/event-evidence';
5 +import { EventList } from '@/components/events/event-row';
6 +import { CountryChip, EventTypeBadge, ImportanceMeter, SignificanceBadge } from '@/components/ui/badges';
7 +import { LiveAgo } from '@/components/ui/live';
8 +import { Container, Note } from '@/components/ui/section';
9 +import { api, ApiError, safe } from '@/lib/api';
10 +import { fmtDateTime, fmtInt } from '@/lib/format';
11 +import { routes, SITE_NAME } from '@/lib/site';
12 +import type { EventDetail } from '@/lib/types';
13 +
14 +export const revalidate = 120;
15 +
16 +async function load(id: string): Promise<EventDetail> {
17 + try {
18 + return await api.event(id);
19 + } catch (e) {
20 + if (e instanceof ApiError && e.notFound) notFound();
21 + throw e;
22 + }
23 +}
24 +
25 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
26 + const { id } = await params;
27 + const e = await safe(api.event(id));
28 + if (!e) return { title: 'Event' };
29 + return { title: e.title, description: e.summary ?? `${e.event_type} event detected for ${e.company.display_name} on ${fmtDateTime(e.detected_at)}.`, alternates: { canonical: `/events/${e.id}` }, openGraph: { title: `${e.title} | ${SITE_NAME}`, description: e.summary ?? undefined, type: 'article', publishedTime: e.detected_at }, robots: e.status === 'active' ? undefined : { index: false } };
30 +}
31 +
32 +export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
33 + const { id } = await params;
34 + const e = await load(id);
35 + const related = await safe(api.companyEvents(e.company.slug, { per_page: 6 }));
36 + const others = (related?.items ?? []).filter((x) => x.id !== e.id).slice(0, 5);
37 + const jsonLd = { '@context': 'https://schema.org', '@type': 'NewsArticle', headline: e.title, datePublished: e.detected_at, dateModified: e.detected_at, about: { '@type': 'Organization', name: e.company.display_name, url: `https://${e.company.canonical_domain}` }, isBasedOn: e.source_url ?? undefined, publisher: { '@type': 'Organization', name: SITE_NAME } };
38 + return (
39 + <Container>
40 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
41 + <div className="pb-4 pt-6 md:pt-10">
42 + <p className="eyebrow flex flex-wrap items-center gap-2">
43 + <Link href={routes.events()} className="hover:text-ink">
44 + Events
45 + </Link>
46 + <span>/</span>
47 + <EventTypeBadge type={e.event_type} subtype={e.event_subtype} />
48 + {e.status !== 'active' && <span className="text-danger">{e.status}</span>}
49 + </p>
50 + <h1 className={`display mt-2 text-[26px] md:text-[36px] ${e.status === 'retracted' ? 'line-through decoration-danger/60' : ''}`}>{e.title}</h1>
51 + <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-ink-2">
52 + <Link href={routes.company(e.company.slug)} className="font-medium text-ink hover:text-accent">
53 + {e.company.display_name}
54 + </Link>
55 + <span className="mono text-xs text-ink-3">{e.company.canonical_domain}</span>
56 + <CountryChip code={e.company.country} />
57 + <span className="inline-flex items-center gap-1.5 text-xs text-ink-3">
58 + <ImportanceMeter importance={e.importance} /> detected <LiveAgo at={e.detected_at} tick={30000} />
59 + </span>
60 + </div>
61 + {e.summary && <p className="mt-4 max-w-3xl text-[16px] leading-relaxed text-ink-2">{e.summary}</p>}
62 + </div>
63 + <div className="grid gap-8 lg:grid-cols-12">
64 + <div className="lg:col-span-8">
65 + <p className="eyebrow mb-2">Evidence</p>
66 + <EventEvidence event={e} sources={e.sources} />
67 + {e.change && (
68 + <section className="mt-6">
69 + <p className="eyebrow mb-2">Underlying change</p>
70 + <div className="flex flex-wrap items-center gap-3 border border-rule p-3 text-sm">
71 + <SignificanceBadge value={e.change.significance} />
72 + <span className="tnum text-ink-2">
73 + +{fmtInt(e.change.blocks_added)} / −{fmtInt(e.change.blocks_removed)} / ~{fmtInt(e.change.blocks_modified)} blocks
74 + </span>
75 + <span className="text-xs text-ink-3">on {e.change.surface} · {fmtDateTime(e.change.detected_at)}</span>
76 + <Link href={routes.change(e.change.id)} className="link ml-auto text-xs">
77 + Open block-level diff →
78 + </Link>
79 + </div>
80 + </section>
81 + )}
82 + {Object.keys(e.payload ?? {}).length > 0 && (
83 + <details className="mt-6">
84 + <summary className="cursor-pointer text-sm text-ink-2 hover:text-ink">Structured payload & entities</summary>
85 + <pre className="mt-2 overflow-x-auto border border-rule bg-surface-2 p-3 text-xs">{JSON.stringify({ payload: e.payload, entities: e.entities }, null, 2)}</pre>
86 + </details>
87 + )}
88 + </div>
89 + <aside className="lg:col-span-4">
90 + <p className="eyebrow mb-2">More from {e.company.display_name}</p>
91 + <EventList events={others} variant="table" showCompany={false} emptyLabel="No other events yet." />
92 + <p className="mt-2 text-sm">
93 + <Link href={routes.company(e.company.slug, 'timeline')} className="link">
94 + Company timeline →
95 + </Link>
96 + </p>
97 + <Note className="mt-6">This event is an interpretation of an observed change on a public web page. It links to the page as it was when detected; if the page has since been removed, the observation is preserved in the snapshot archive.</Note>
98 + </aside>
99 + </div>
100 + </Container>
101 + );
102 +}
added apps/web/src/app/events/page.tsx +63 −0
@@ -0,0 +1,63 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { EventFilters } from '@/components/events/event-filters';
4 +import { EventList } from '@/components/events/event-row';
5 +import { EventTypeBadge } from '@/components/ui/badges';
6 +import { Pagination, withParams } from '@/components/ui/pagination';
7 +import { Container, Note, PageHeader, Unavailable } from '@/components/ui/section';
8 +import { api, safe } from '@/lib/api';
9 +import { fmtInt, fmtPctSigned } from '@/lib/format';
10 +import { flat, int, str, type SP } from '@/lib/params';
11 +
12 +export const metadata: Metadata = { title: 'Events', description: 'Structured corporate events detected across monitored public surfaces, with filters by type, importance, confidence, country and industry.' };
13 +export const revalidate = 60;
14 +
15 +export default async function EventsPage({ searchParams }: { searchParams: Promise<SP> }) {
16 + const sp = await searchParams;
17 + const cur = flat(sp);
18 + const page = int(sp.page, 1);
19 + const query = { event_type: str(sp.event_type), event_subtype: str(sp.event_subtype), country: str(sp.country), industry: str(sp.industry), min_importance: str(sp.min_importance), min_confidence: str(sp.min_confidence), q: str(sp.q), origin: str(sp.origin), surface: str(sp.surface), company: str(sp.company), sort: str(sp.sort), since: str(sp.since), until: str(sp.until), page, per_page: 50 };
20 + const [data, summary, countries, industries] = await Promise.all([safe(api.events(query)), safe(api.eventSummary(7, 'type')), safe(api.countries()), safe(api.industries())]);
21 + return (
22 + <Container wide>
23 + <PageHeader eyebrow="Events" title="Structured corporate events" lede="Each event is an interpreted change on a monitored public page, with the source URL, detection time, before/after values and a confidence label. Wording is deliberately careful: a listing that disappears is “no longer listed”, never more." />
24 + {summary?.items.length ? (
25 + <div className="no-scrollbar -mx-4 mb-4 flex gap-4 overflow-x-auto px-4 pb-1 md:mx-0 md:px-0">
26 + {summary.items.slice(0, 12).map((s) => (
27 + <div key={s.key} className="shrink-0">
28 + <EventTypeBadge type={s.key} small />
29 + <p className="tnum mt-0.5 text-sm font-medium">
30 + {fmtInt(s.count)} <span className={`text-[11px] font-normal ${(s.delta_pct ?? 0) > 0 ? 'text-positive' : (s.delta_pct ?? 0) < 0 ? 'text-danger' : 'text-ink-3'}`}>{s.delta_pct === null ? '' : fmtPctSigned(s.delta_pct, 0)}</span>
31 + </p>
32 + </div>
33 + ))}
34 + <p className="shrink-0 self-end text-[10px] uppercase tracking-wider text-ink-3">last 7 days vs previous 7</p>
35 + </div>
36 + ) : null}
37 + <Suspense>
38 + <EventFilters countries={(countries?.items ?? []).map((c) => ({ value: c.code, label: c.name }))} industries={(industries?.items ?? []).map((i) => ({ value: i.slug, label: i.name }))} className="mb-4" />
39 + </Suspense>
40 + <form method="get" action="/events" className="mb-3 flex max-w-lg gap-2">
41 + {Object.entries(cur)
42 + .filter(([k, v]) => k !== 'q' && k !== 'page' && v)
43 + .map(([k, v]) => (
44 + <input key={k} type="hidden" name={k} value={v} />
45 + ))}
46 + <input type="search" name="q" defaultValue={str(sp.q)} placeholder="Search event titles and summaries…" className="field flex-1" aria-label="Search events" />
47 + <button type="submit" className="btn">
48 + Search
49 + </button>
50 + </form>
51 + {!data ? (
52 + <Unavailable what="Events" />
53 + ) : (
54 + <>
55 + <p className="tnum mb-1 text-xs text-ink-3">{fmtInt(data.total)} events</p>
56 + <EventList events={data.items} variant="table" />
57 + <Pagination total={data.total} page={data.page} pages={data.pages} perPage={data.per_page} makeHref={(p) => withParams('/events', cur, { page: p > 1 ? p : undefined })} className="mt-4" />
58 + </>
59 + )}
60 + <Note className="mt-6">Retracted events remain visible with a strike-through and are excluded from metrics. Duplicate detections are merged into one canonical event whose sources list every surface that corroborated it.</Note>
61 + </Container>
62 + );
63 +}
added apps/web/src/app/globals.css +739 −0
@@ -0,0 +1,739 @@
1 +@import 'tailwindcss';
2 +
3 +/* =====================================================================================================================
4 + Company Atlas — design tokens
5 + A premium intelligence terminal blended with a modern data atlas: dense but readable, hairlines over cards, tabular
6 + numbers, one accent (atlas blue), semantic greens/ambers/reds, and a fixed hue per event type so the live feed reads at
7 + a glance. Two themes on <html data-theme="light|dark"> (default follows the system; prepaint script avoids flashes).
8 + ===================================================================================================================== */
9 +
10 +:root,
11 +[data-theme='light'] {
12 + color-scheme: light;
13 + --canvas: #f7f7f4;
14 + --surface: #ffffff;
15 + --surface-2: #efefeb;
16 + --surface-3: #e4e5df;
17 + --ink: #0f1419;
18 + --ink-2: #4a5160;
19 + --ink-3: #7b8294;
20 + --rule: rgba(15, 20, 25, 0.1);
21 + --rule-strong: rgba(15, 20, 25, 0.22);
22 + --accent: #1f4fd8;
23 + --accent-ink: #ffffff;
24 + --accent-soft: rgba(31, 79, 216, 0.1);
25 + --positive: #16a34a;
26 + --positive-soft: rgba(22, 163, 74, 0.12);
27 + --warning: #b26a05;
28 + --warning-soft: rgba(217, 134, 12, 0.14);
29 + --danger: #c0333d;
30 + --danger-soft: rgba(192, 51, 61, 0.12);
31 + --live: #16a34a;
32 +
33 + /* event-type hues (spec §21) */
34 + --ev-product: #6d3fc7;
35 + --ev-pricing: #b26a05;
36 + --ev-hiring: #0d7c86;
37 + --ev-leadership: #c2366f;
38 + --ev-location: #2a7f3f;
39 + --ev-developer: #1f4fd8;
40 + --ev-legal: #5b6478;
41 + --ev-communication: #4b4fc9;
42 + --ev-financing: #9a6d00;
43 + --ev-ma: #9a6d00;
44 + --ev-partnership: #0f7ca3;
45 + --ev-strategy: #7a3fa8;
46 + --ev-technology: #0c6fb0;
47 + --ev-marketing: #b8407e;
48 + --ev-security: #c0333d;
49 + --ev-operations: #6b5e4a;
50 + --ev-sustainability: #4f7f13;
51 + --ev-investor_relations: #8a5a1c;
52 + --ev-other: #7b8294;
53 +
54 + /* sensor tiers A–E (fresh → slow) and confidence */
55 + --tier-a: #16a34a;
56 + --tier-b: #1f4fd8;
57 + --tier-c: #0d7c86;
58 + --tier-d: #b26a05;
59 + --tier-e: #7b8294;
60 +
61 + --series-1: #1f4fd8;
62 + --series-2: #c2560f;
63 + --series-3: #16a34a;
64 + --series-4: #6d3fc7;
65 + --series-5: #0d7c86;
66 + --series-6: #c2366f;
67 +
68 + /* map */
69 + --map-land: #e6e7e1;
70 + --map-stroke: #ffffff;
71 + --map-bubble: rgba(31, 79, 216, 0.55);
72 + --map-bubble-stroke: #1f4fd8;
73 +
74 + /* brand plate (LogoMark) inverted per theme */
75 + --brand-plate: #0f1419;
76 + --brand-ink: #f7f7f4;
77 + --brand-grid: rgba(247, 247, 244, 0.22);
78 + --brand-accent: #3fd07a;
79 +
80 + --radius: 4px;
81 + --radius-lg: 8px;
82 + --header-h: 56px;
83 + --tabbar-h: 58px;
84 +
85 + /* density (html[data-density="compact"]) */
86 + --d-base: 15px;
87 + --d-cell-y: 0.55rem;
88 + --d-cell-x: 0.75rem;
89 + --d-kv-y: 0.5rem;
90 + --d-row-y: 0.625rem;
91 + --d-section-y: 1.75rem;
92 + --d-section-y-md: 2.5rem;
93 + --d-table-fs: 0.875rem;
94 +}
95 +[data-density='compact'] {
96 + --d-base: 14px;
97 + --d-cell-y: 0.36rem;
98 + --d-cell-x: 0.6rem;
99 + --d-kv-y: 0.3rem;
100 + --d-row-y: 0.42rem;
101 + --d-section-y: 1.2rem;
102 + --d-section-y-md: 1.7rem;
103 + --d-table-fs: 0.8125rem;
104 +}
105 +
106 +[data-theme='dark'] {
107 + color-scheme: dark;
108 + --canvas: #0a0d12;
109 + --surface: #11151c;
110 + --surface-2: #171c25;
111 + --surface-3: #202632;
112 + --ink: #e8ebf1;
113 + --ink-2: #a3a9b8;
114 + --ink-3: #6f7688;
115 + --rule: rgba(190, 200, 225, 0.11);
116 + --rule-strong: rgba(190, 200, 225, 0.26);
117 + --accent: #6d95ff;
118 + --accent-ink: #061024;
119 + --accent-soft: rgba(109, 149, 255, 0.14);
120 + --positive: #3fd07a;
121 + --positive-soft: rgba(63, 208, 122, 0.14);
122 + --warning: #f2b04a;
123 + --warning-soft: rgba(242, 176, 74, 0.14);
124 + --danger: #ff6b74;
125 + --danger-soft: rgba(255, 107, 116, 0.14);
126 + --live: #3fd07a;
127 +
128 + --ev-product: #b08cff;
129 + --ev-pricing: #f2b04a;
130 + --ev-hiring: #3fc3cf;
131 + --ev-leadership: #ff7ab6;
132 + --ev-location: #5ed37f;
133 + --ev-developer: #6d95ff;
134 + --ev-legal: #9aa3b8;
135 + --ev-communication: #9a9dff;
136 + --ev-financing: #e2c04a;
137 + --ev-ma: #e2c04a;
138 + --ev-partnership: #4fb8e8;
139 + --ev-strategy: #c49bff;
140 + --ev-technology: #57b7f0;
141 + --ev-marketing: #ff8ccf;
142 + --ev-security: #ff6b74;
143 + --ev-operations: #c2ad8a;
144 + --ev-sustainability: #a3d95b;
145 + --ev-investor_relations: #d9a25f;
146 + --ev-other: #8b92a5;
147 +
148 + --tier-a: #3fd07a;
149 + --tier-b: #6d95ff;
150 + --tier-c: #3fc3cf;
151 + --tier-d: #f2b04a;
152 + --tier-e: #8b92a5;
153 +
154 + --series-1: #6d95ff;
155 + --series-2: #ff9a4d;
156 + --series-3: #3fd07a;
157 + --series-4: #b08cff;
158 + --series-5: #3fc3cf;
159 + --series-6: #ff7ab6;
160 +
161 + --map-land: #1c222d;
162 + --map-stroke: #0a0d12;
163 + --map-bubble: rgba(109, 149, 255, 0.5);
164 + --map-bubble-stroke: #8fadff;
165 +
166 + --brand-plate: #e8ebf1;
167 + --brand-ink: #0a0d12;
168 + --brand-grid: rgba(10, 13, 18, 0.2);
169 + --brand-accent: #16a34a;
170 +}
171 +
172 +@theme inline {
173 + --color-canvas: var(--canvas);
174 + --color-surface: var(--surface);
175 + --color-surface-2: var(--surface-2);
176 + --color-surface-3: var(--surface-3);
177 + --color-ink: var(--ink);
178 + --color-ink-2: var(--ink-2);
179 + --color-ink-3: var(--ink-3);
180 + --color-rule: var(--rule);
181 + --color-rule-strong: var(--rule-strong);
182 + --color-accent: var(--accent);
183 + --color-accent-ink: var(--accent-ink);
184 + --color-accent-soft: var(--accent-soft);
185 + --color-positive: var(--positive);
186 + --color-positive-soft: var(--positive-soft);
187 + --color-warning: var(--warning);
188 + --color-warning-soft: var(--warning-soft);
189 + --color-danger: var(--danger);
190 + --color-danger-soft: var(--danger-soft);
191 + --color-live: var(--live);
192 +
193 + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
194 + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
195 +
196 + --radius-sm: var(--radius);
197 + --radius-md: var(--radius);
198 + --radius-lg: var(--radius-lg);
199 + --radius-xl: 12px;
200 +
201 + --text-2xs: 0.6875rem;
202 + --text-2xs--line-height: 1rem;
203 +}
204 +
205 +@layer base {
206 + html {
207 + background: var(--canvas);
208 + color: var(--ink);
209 + -webkit-text-size-adjust: 100%;
210 + text-rendering: optimizeLegibility;
211 + font-feature-settings: 'cv11', 'ss01';
212 + scroll-padding-top: calc(var(--header-h) + 12px);
213 + }
214 + body {
215 + font-family: var(--font-sans);
216 + font-size: var(--d-base, 15px);
217 + line-height: 1.5;
218 + background: var(--canvas);
219 + color: var(--ink);
220 + }
221 + ::selection {
222 + background: var(--accent-soft);
223 + }
224 + :focus-visible {
225 + outline: 2px solid var(--accent);
226 + outline-offset: 2px;
227 + }
228 + h1,
229 + h2,
230 + h3,
231 + h4 {
232 + letter-spacing: -0.01em;
233 + text-wrap: balance;
234 + }
235 + code,
236 + kbd,
237 + pre,
238 + samp {
239 + font-family: var(--font-mono);
240 + }
241 + table {
242 + font-variant-numeric: tabular-nums;
243 + }
244 + a {
245 + text-underline-offset: 3px;
246 + }
247 + button {
248 + cursor: pointer;
249 + }
250 + input,
251 + select,
252 + textarea {
253 + font: inherit;
254 + color: inherit;
255 + }
256 + input::placeholder {
257 + color: var(--ink-3);
258 + }
259 + input[type='search']::-webkit-search-decoration,
260 + input[type='search']::-webkit-search-cancel-button {
261 + -webkit-appearance: none;
262 + }
263 + select {
264 + background: var(--surface);
265 + }
266 +}
267 +
268 +/* ---------------------------------------------------------------------------------------------------------- utilities */
269 +.container-x {
270 + padding-left: max(1rem, env(safe-area-inset-left));
271 + padding-right: max(1rem, env(safe-area-inset-right));
272 +}
273 +@media (min-width: 768px) {
274 + .container-x {
275 + padding-left: 2rem;
276 + padding-right: 2rem;
277 + }
278 +}
279 +.eyebrow {
280 + font-size: 0.6875rem;
281 + line-height: 1rem;
282 + letter-spacing: 0.1em;
283 + text-transform: uppercase;
284 + font-weight: 600;
285 + color: var(--ink-3);
286 +}
287 +.display {
288 + font-weight: 600;
289 + letter-spacing: -0.03em;
290 + line-height: 1.02;
291 + text-wrap: balance;
292 +}
293 +.mono {
294 + font-family: var(--font-mono);
295 + font-feature-settings: 'tnum', 'zero';
296 +}
297 +.tnum {
298 + font-variant-numeric: tabular-nums;
299 + font-feature-settings: 'tnum';
300 +}
301 +.hairline {
302 + border-top: 1px solid var(--rule);
303 +}
304 +.link {
305 + color: var(--accent);
306 + text-decoration: none;
307 +}
308 +.link:hover {
309 + text-decoration: underline;
310 +}
311 +.panel {
312 + background: var(--surface);
313 + border: 1px solid var(--rule);
314 + border-radius: var(--radius-lg);
315 +}
316 +.grid-bg {
317 + background-image: linear-gradient(to right, var(--rule) 1px, transparent 1px), linear-gradient(to bottom, var(--rule) 1px, transparent 1px);
318 + background-size: 48px 48px;
319 + mask-image: radial-gradient(ellipse at 50% 0%, rgba(0, 0, 0, 0.9), transparent 75%);
320 +}
321 +.scrollbar-thin {
322 + scrollbar-width: thin;
323 + scrollbar-color: var(--rule-strong) transparent;
324 +}
325 +.scrollbar-thin::-webkit-scrollbar {
326 + height: 6px;
327 + width: 6px;
328 +}
329 +.scrollbar-thin::-webkit-scrollbar-thumb {
330 + background: var(--rule-strong);
331 + border-radius: 3px;
332 +}
333 +.no-scrollbar {
334 + scrollbar-width: none;
335 +}
336 +.no-scrollbar::-webkit-scrollbar {
337 + display: none;
338 +}
339 +.safe-bottom {
340 + padding-bottom: env(safe-area-inset-bottom, 0px);
341 +}
342 +.prose-atlas p + p {
343 + margin-top: 0.75rem;
344 +}
345 +.prose-atlas a {
346 + color: var(--accent);
347 +}
348 +.prose-atlas a:hover {
349 + text-decoration: underline;
350 +}
351 +.prose-atlas h2 {
352 + margin-top: 2rem;
353 + font-size: 1.125rem;
354 + font-weight: 600;
355 +}
356 +.prose-atlas h3 {
357 + margin-top: 1.25rem;
358 + font-size: 1rem;
359 + font-weight: 600;
360 +}
361 +.prose-atlas ul {
362 + margin-top: 0.5rem;
363 + padding-left: 1.1rem;
364 + list-style: disc;
365 +}
366 +.prose-atlas li + li {
367 + margin-top: 0.25rem;
368 +}
369 +.prose-atlas code {
370 + font-size: 0.85em;
371 + background: var(--surface-2);
372 + padding: 0.05rem 0.3rem;
373 + border-radius: 3px;
374 +}
375 +.prose-atlas pre {
376 + margin-top: 0.75rem;
377 + padding: 0.75rem 0.9rem;
378 + background: var(--surface-2);
379 + border: 1px solid var(--rule);
380 + border-radius: var(--radius);
381 + font-size: 0.8125rem;
382 + overflow-x: auto;
383 +}
384 +.prose-atlas pre code {
385 + background: none;
386 + padding: 0;
387 +}
388 +.btn {
389 + display: inline-flex;
390 + align-items: center;
391 + justify-content: center;
392 + gap: 0.4rem;
393 + min-height: 2.5rem;
394 + padding: 0 0.85rem;
395 + border: 1px solid var(--rule-strong);
396 + border-radius: var(--radius);
397 + font-size: 0.875rem;
398 + color: var(--ink-2);
399 + background: var(--surface);
400 + white-space: nowrap;
401 +}
402 +.btn:hover {
403 + color: var(--ink);
404 + border-color: var(--ink-3);
405 +}
406 +.btn-primary {
407 + background: var(--ink);
408 + color: var(--canvas);
409 + border-color: var(--ink);
410 +}
411 +.btn-primary:hover {
412 + color: var(--canvas);
413 + opacity: 0.92;
414 +}
415 +.btn-accent {
416 + background: var(--accent);
417 + color: var(--accent-ink);
418 + border-color: var(--accent);
419 +}
420 +.btn-accent:hover {
421 + color: var(--accent-ink);
422 + opacity: 0.92;
423 +}
424 +.btn-sm {
425 + min-height: 2rem;
426 + padding: 0 0.6rem;
427 + font-size: 0.8125rem;
428 +}
429 +.field {
430 + height: 2.5rem;
431 + min-width: 0;
432 + padding: 0 0.65rem;
433 + border: 1px solid var(--rule-strong);
434 + border-radius: var(--radius);
435 + background: var(--surface);
436 + font-size: 0.875rem;
437 + color: var(--ink);
438 +}
439 +.field:focus {
440 + outline: none;
441 + border-color: var(--accent);
442 +}
443 +.chip-btn {
444 + display: inline-flex;
445 + align-items: center;
446 + gap: 0.3rem;
447 + min-height: 2rem;
448 + padding: 0 0.6rem;
449 + border: 1px solid var(--rule);
450 + border-radius: 999px;
451 + background: var(--surface);
452 + font-size: 0.75rem;
453 + color: var(--ink-2);
454 + white-space: nowrap;
455 +}
456 +.chip-btn:hover {
457 + border-color: var(--rule-strong);
458 + color: var(--ink);
459 +}
460 +.chip-btn[aria-pressed='true'],
461 +.chip-btn[data-on='true'] {
462 + background: var(--ink);
463 + border-color: var(--ink);
464 + color: var(--canvas);
465 +}
466 +
467 +/* Live indicator */
468 +.dot {
469 + display: inline-block;
470 + width: 7px;
471 + height: 7px;
472 + border-radius: 999px;
473 + background: var(--live);
474 + vertical-align: middle;
475 +}
476 +.pulse {
477 + position: relative;
478 +}
479 +.pulse::after {
480 + content: '';
481 + position: absolute;
482 + inset: -3px;
483 + border-radius: 999px;
484 + border: 1px solid var(--live);
485 + animation: pulse-ring 1.8s ease-out infinite;
486 +}
487 +@keyframes pulse-ring {
488 + 0% {
489 + transform: scale(0.6);
490 + opacity: 0.9;
491 + }
492 + 100% {
493 + transform: scale(1.8);
494 + opacity: 0;
495 + }
496 +}
497 +/* New live event: soft highlight that fades */
498 +.feed-new {
499 + animation: feed-in 1.6s ease-out;
500 +}
501 +@keyframes feed-in {
502 + 0% {
503 + background: var(--positive-soft);
504 + transform: translateY(-4px);
505 + opacity: 0.4;
506 + }
507 + 30% {
508 + opacity: 1;
509 + transform: translateY(0);
510 + }
511 + 100% {
512 + background: transparent;
513 + }
514 +}
515 +.counter-tick {
516 + animation: counter-tick 500ms ease-out;
517 +}
518 +@keyframes counter-tick {
519 + 0% {
520 + color: var(--positive);
521 + }
522 + 100% {
523 + color: inherit;
524 + }
525 +}
526 +
527 +/* Grid/flex children default to min-width:auto, which lets a horizontally scrolling table widen the page on mobile. */
528 +[class*='grid-cols'] > *,
529 +.min-w-0 {
530 + min-width: 0;
531 +}
532 +@media (max-width: 767px) {
533 + .btn-sm {
534 + min-height: 2.5rem;
535 + }
536 + .chip-btn {
537 + min-height: 2.25rem;
538 + }
539 +}
540 +
541 +/* ---------------------------------------------------------------------------------------------------------- data tables */
542 +.data-table {
543 + width: 100%;
544 + border-collapse: collapse;
545 + font-variant-numeric: tabular-nums;
546 + font-size: var(--d-table-fs, 0.875rem);
547 +}
548 +.data-table th {
549 + text-align: left;
550 + font-weight: 600;
551 + font-size: 0.6875rem;
552 + letter-spacing: 0.08em;
553 + text-transform: uppercase;
554 + color: var(--ink-3);
555 + padding: calc(var(--d-cell-y) * 0.9) var(--d-cell-x);
556 + border-bottom: 1px solid var(--rule-strong);
557 + white-space: nowrap;
558 + vertical-align: bottom;
559 +}
560 +.data-table th:first-child,
561 +.data-table td:first-child {
562 + padding-left: 0;
563 +}
564 +.data-table th:last-child,
565 +.data-table td:last-child {
566 + padding-right: 0;
567 +}
568 +.data-table td {
569 + padding: var(--d-cell-y) var(--d-cell-x);
570 + border-bottom: 1px solid var(--rule);
571 + vertical-align: middle;
572 +}
573 +.data-table tbody tr:hover {
574 + background: var(--surface-2);
575 +}
576 +.data-table .num {
577 + text-align: right;
578 + font-variant-numeric: tabular-nums;
579 +}
580 +.data-table th.num {
581 + text-align: right;
582 +}
583 +.data-table .primary {
584 + font-weight: 500;
585 + color: var(--ink);
586 +}
587 +.data-table a.row-link {
588 + color: inherit;
589 + text-decoration: none;
590 +}
591 +.data-table a.row-link:hover {
592 + color: var(--accent);
593 +}
594 +.table-scroll {
595 + position: relative;
596 + overflow-x: auto;
597 + -webkit-overflow-scrolling: touch;
598 + scrollbar-width: thin;
599 + max-width: 100%;
600 +}
601 +.table-scroll .data-table th,
602 +.table-scroll .data-table td {
603 + white-space: nowrap;
604 +}
605 +.table-scroll .data-table td.wrap {
606 + white-space: normal;
607 + min-width: 14rem;
608 +}
609 +.table-scroll .data-table th:first-child,
610 +.table-scroll .data-table td:first-child {
611 + padding-left: 0.25rem;
612 +}
613 +.table-scroll .data-table th:last-child,
614 +.table-scroll .data-table td:last-child {
615 + padding-right: 0.25rem;
616 +}
617 +
618 +/* ---------------------------------------------------------------------------------------------------------- key–value */
619 +.kv {
620 + display: grid;
621 + grid-template-columns: minmax(0, 1fr);
622 + font-size: 0.875rem;
623 +}
624 +.kv > div {
625 + display: grid;
626 + grid-template-columns: 9.5rem minmax(0, 1fr);
627 + gap: 0.25rem 1rem;
628 + padding: var(--d-kv-y) 0;
629 + border-bottom: 1px solid var(--rule);
630 + align-items: baseline;
631 +}
632 +.kv > div > dt {
633 + color: var(--ink-3);
634 + font-size: 0.8125rem;
635 +}
636 +.kv > div > dd {
637 + min-width: 0;
638 + overflow-wrap: anywhere;
639 +}
640 +@media (max-width: 480px) {
641 + .kv > div {
642 + grid-template-columns: 7rem minmax(0, 1fr);
643 + }
644 +}
645 +
646 +.section-y {
647 + padding-top: var(--d-section-y);
648 + padding-bottom: var(--d-section-y);
649 +}
650 +@media (min-width: 768px) {
651 + .section-y {
652 + padding-top: var(--d-section-y-md);
653 + padding-bottom: var(--d-section-y-md);
654 + }
655 +}
656 +.row-y {
657 + padding-top: var(--d-row-y);
658 + padding-bottom: var(--d-row-y);
659 +}
660 +
661 +/* ---------------------------------------------------------------------------------------------------------- diff viewer */
662 +.diff-block {
663 + border-left: 3px solid var(--rule-strong);
664 + padding: 0.5rem 0.75rem;
665 + font-size: 0.8125rem;
666 + line-height: 1.45;
667 + overflow-wrap: anywhere;
668 +}
669 +.diff-block.added {
670 + border-color: var(--positive);
671 + background: var(--positive-soft);
672 +}
673 +.diff-block.removed {
674 + border-color: var(--danger);
675 + background: var(--danger-soft);
676 +}
677 +.diff-block.modified {
678 + border-color: var(--warning);
679 +}
680 +.diff-before {
681 + color: var(--ink-3);
682 + text-decoration: line-through;
683 + text-decoration-color: var(--danger);
684 +}
685 +.diff-after {
686 + color: var(--ink);
687 +}
688 +
689 +/* meter */
690 +.meter {
691 + position: relative;
692 + height: 4px;
693 + background: var(--surface-3);
694 + border-radius: 2px;
695 + overflow: hidden;
696 +}
697 +.meter > span {
698 + position: absolute;
699 + inset: 0 auto 0 0;
700 + background: var(--accent);
701 + border-radius: 2px;
702 +}
703 +
704 +.sheet-enter {
705 + animation: sheet-in 160ms ease-out;
706 +}
707 +@keyframes sheet-in {
708 + from {
709 + opacity: 0;
710 + transform: translateY(8px);
711 + }
712 + to {
713 + opacity: 1;
714 + transform: translateY(0);
715 + }
716 +}
717 +.skeleton {
718 + background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 50%, var(--surface-2) 75%);
719 + background-size: 200% 100%;
720 + animation: shimmer 1.4s linear infinite;
721 + border-radius: var(--radius);
722 +}
723 +@keyframes shimmer {
724 + from {
725 + background-position: 200% 0;
726 + }
727 + to {
728 + background-position: -200% 0;
729 + }
730 +}
731 +
732 +@media (prefers-reduced-motion: reduce) {
733 + *,
734 + *::before,
735 + *::after {
736 + animation-duration: 0.01ms !important;
737 + transition-duration: 0.01ms !important;
738 + }
739 +}
added apps/web/src/app/icon-512/route.tsx +16 −0
@@ -0,0 +1,16 @@
1 +import { ImageResponse } from 'next/og';
2 +import { MARK_DARK, MarkImg } from '@/components/brand/mark';
3 +
4 +/** 512×512 maskable PWA icon (the mark inset in a solid plate so masks never clip the globe). */
5 +export const dynamic = 'force-static';
6 +
7 +export function GET() {
8 + return new ImageResponse(
9 + (
10 + <div style={{ width: 512, height: 512, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
11 + <MarkImg px={400} colors={{ ...MARK_DARK, plate: MARK_DARK.plate }} radius={0} />
12 + </div>
13 + ),
14 + { width: 512, height: 512 },
15 + );
16 +}
added apps/web/src/app/icon.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"><rect x="1" y="1" width="30" height="30" rx="7" fill="#0a0d12"/><circle cx="16" cy="16" r="10" stroke="#e8ebf1" stroke-width="1.6" fill="none"/><ellipse cx="16" cy="16" rx="4.2" ry="10" stroke="rgba(232,235,241,0.34)" stroke-width="1.1" fill="none"/><line x1="6" x2="26" y1="16" y2="16" stroke="#e8ebf1" stroke-width="1.6" stroke-linecap="round"/><circle cx="26" cy="16" r="2.6" fill="#3fd07a"/></svg>
added apps/web/src/app/industry/[slug]/page.tsx +111 −0
@@ -0,0 +1,111 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { Bars } from '@/components/charts/bars';
5 +import { LineChart } from '@/components/charts/line-chart';
6 +import { Sparkline } from '@/components/charts/sparkline';
7 +import { CompanyTable } from '@/components/company/company-table';
8 +import { EventList } from '@/components/events/event-row';
9 +import { EventTypeBadge } from '@/components/ui/badges';
10 +import { Container, Empty, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
11 +import { api, ApiError, safe } from '@/lib/api';
12 +import { countryName } from '@/lib/countries';
13 +import { fmtInt, fmtPctSigned, fmtScore } from '@/lib/format';
14 +import { routes } from '@/lib/site';
15 +import type { CompanyCard, IndustryDetailRaw } from '@/lib/types';
16 +
17 +export const revalidate = 300;
18 +
19 +async function load(slug: string): Promise<IndustryDetailRaw> {
20 + try {
21 + return await api.industry(slug);
22 + } catch (e) {
23 + if (e instanceof ApiError && e.notFound) notFound();
24 + throw e;
25 + }
26 +}
27 +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
28 + const { slug } = await params;
29 + const d = await safe(api.industry(slug));
30 + if (!d) return { title: 'Industry' };
31 + return { title: `${d.name} — industry atlas`, description: `${d.name}: ${Array.isArray(d.companies) ? d.companies.length : d.companies} monitored companies, ${d.events_30d} structured events in 30 days, hiring momentum ${fmtPctSigned(d.hiring_momentum_30d)}.`, alternates: { canonical: `/industry/${slug}` } };
32 +}
33 +
34 +export default async function IndustryPage({ params }: { params: Promise<{ slug: string }> }) {
35 + const { slug } = await params;
36 + const d = await load(slug);
37 + const companies: CompanyCard[] = Array.isArray(d.companies) ? d.companies : [];
38 + const count = Array.isArray(d.companies) ? d.companies.length : d.companies;
39 + const countries = d.countries ?? [];
40 + return (
41 + <Container wide>
42 + <PageHeader
43 + eyebrow={
44 + <>
45 + <Link href={routes.industries()} className="hover:text-ink">
46 + Industry atlas
47 + </Link>
48 + {d.parent_slug && (
49 + <>
50 + <span>/</span>
51 + <Link href={routes.industry(d.parent_slug)} className="hover:text-ink">
52 + {d.parent_slug.replace(/-/g, ' ')}
53 + </Link>
54 + </>
55 + )}
56 + </>
57 + }
58 + title={d.name}
59 + lede={d.description ?? `Monitored companies classified under ${d.name}.`}
60 + />
61 + <StatGrid cols={6}>
62 + <Stat label="Companies" value={fmtInt(count)} size="sm" />
63 + <Stat label="Events 7 d" value={fmtInt(d.events_7d)} size="sm" />
64 + <Stat label="Events 30 d" value={fmtInt(d.events_30d)} size="sm" />
65 + <Stat label="Activity" value={fmtScore(d.activity_score)} size="sm" />
66 + <Stat label="Hiring 30 d" value={fmtPctSigned(d.hiring_momentum_30d)} size="sm" delta={d.hiring ? { value: `${fmtInt(d.hiring.open)} open`, tone: 'neutral' } : undefined} />
67 + <Stat label="AI adoption" value={fmtScore(d.ai_adoption)} size="sm" />
68 + </StatGrid>
69 + <div className="grid gap-8 lg:grid-cols-12">
70 + <Section eyebrow="Activity" title="Industry activity, 90 days" className="lg:col-span-8">
71 + {d.series?.length > 1 ? <LineChart series={[{ id: 'activity', label: 'Activity score', points: d.series.map((p) => ({ day: p.day, value: p.value })) }]} height={200} yZero /> : <Empty compact title="Not enough history yet." />}
72 + </Section>
73 + <Section eyebrow="Composition" title="Countries" className="lg:col-span-4">
74 + {countries.length ? <Bars dense rows={countries.slice(0, 10).map((c) => ({ key: c.country, label: countryName(c.country), value: c.companies, href: routes.country(c.country) }))} /> : <Empty compact />}
75 + {d.top_event_types?.length > 0 && (
76 + <p className="mt-4 flex flex-wrap items-center gap-1 text-xs text-ink-3">
77 + Top event types:
78 + {d.top_event_types.map((t) => (
79 + <EventTypeBadge key={t} type={t} small />
80 + ))}
81 + </p>
82 + )}
83 + </Section>
84 + </div>
85 + <Section eyebrow="Companies" title="Most active monitored companies" action={{ href: routes.companies({ industry: slug }), label: 'All in directory' }}>
86 + <CompanyTable items={companies} />
87 + </Section>
88 + <div className="grid gap-8 lg:grid-cols-12">
89 + <Section eyebrow="Events" title="Latest structured events" action={{ href: routes.events({ industry: slug }), label: 'All events' }} className="lg:col-span-8">
90 + <EventList events={d.events ?? []} variant="table" />
91 + </Section>
92 + <Section eyebrow="Trending" title="Terms gaining momentum" className="lg:col-span-4">
93 + {d.trending?.length ? (
94 + <ul className="divide-y divide-rule border-y border-rule text-sm">
95 + {d.trending.slice(0, 8).map((t) => (
96 + <li key={t.term} className="flex items-center gap-2 py-1.5">
97 + <span className="min-w-0 flex-1 truncate">{t.term}</span>
98 + <Sparkline values={t.series} width={48} height={14} />
99 + <span className={`tnum text-xs ${(t.momentum ?? 0) > 0 ? 'text-positive' : 'text-ink-3'}`}>{fmtPctSigned(t.momentum, 0)}</span>
100 + </li>
101 + ))}
102 + </ul>
103 + ) : (
104 + <Empty compact />
105 + )}
106 + <Note className="mt-3">Hiring: {d.hiring ? `${fmtInt(d.hiring.new_30d)} new and ${fmtInt(d.hiring.removed_30d)} no-longer-listed roles in 30 days across monitored careers pages.` : 'summary unavailable.'}</Note>
107 + </Section>
108 + </div>
109 + </Container>
110 + );
111 +}
added apps/web/src/app/industry/page.tsx +87 −0
@@ -0,0 +1,87 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EventTypeBadge } from '@/components/ui/badges';
4 +import { Container, PageHeader, Unavailable } from '@/components/ui/section';
5 +import { api, safe } from '@/lib/api';
6 +import { cn } from '@/lib/cn';
7 +import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +
10 +export const metadata: Metadata = { title: 'Industry atlas', description: 'A living index per industry: monitored companies, events, hiring momentum, activity and AI adoption.' };
11 +export const revalidate = 300;
12 +
13 +export default async function IndustriesPage() {
14 + const data = await safe(api.industries());
15 + return (
16 + <Container wide>
17 + <PageHeader eyebrow="Industry atlas" title="Industries" lede="Each industry is a living index over its monitored companies: activity, structured events, hiring momentum and observable AI adoption. Values are averages over the monitored population, not market statistics." />
18 + {!data ? (
19 + <Unavailable what="Industries" />
20 + ) : (
21 + <>
22 + <div className="table-scroll hidden md:block">
23 + <table className="data-table">
24 + <thead>
25 + <tr>
26 + <th>Industry</th>
27 + <th className="num">Companies</th>
28 + <th className="num">Events 7 d</th>
29 + <th className="num">Events 30 d</th>
30 + <th className="num">Activity</th>
31 + <th className="num">Hiring 30 d</th>
32 + <th className="num">AI adoption</th>
33 + <th>Top event types</th>
34 + </tr>
35 + </thead>
36 + <tbody>
37 + {data.items.map((i) => {
38 + const h = num(i.hiring_momentum_30d);
39 + return (
40 + <tr key={i.slug}>
41 + <td className="primary">
42 + <Link href={routes.industry(i.slug)} className="row-link">
43 + {i.name}
44 + </Link>
45 + {i.parent_slug && <span className="block text-[11px] font-normal text-ink-3">in {i.parent_slug.replace(/-/g, ' ')}</span>}
46 + </td>
47 + <td className="num tnum">{fmtInt(i.companies)}</td>
48 + <td className="num tnum">{fmtInt(i.events_7d)}</td>
49 + <td className="num tnum">{fmtInt(i.events_30d)}</td>
50 + <td className="num tnum">{fmtScore(i.activity_score)}</td>
51 + <td className={cn('num tnum', h !== null && (h > 0 ? 'text-positive' : h < 0 ? 'text-danger' : ''))}>{h === null ? '—' : fmtPctSigned(h)}</td>
52 + <td className="num tnum">{fmtScore(i.ai_adoption)}</td>
53 + <td>
54 + <span className="flex flex-wrap gap-1">
55 + {i.top_event_types.slice(0, 3).map((t) => (
56 + <EventTypeBadge key={t} type={t} small />
57 + ))}
58 + </span>
59 + </td>
60 + </tr>
61 + );
62 + })}
63 + </tbody>
64 + </table>
65 + </div>
66 + <ul className="divide-y divide-rule border-y border-rule md:hidden">
67 + {data.items.map((i) => (
68 + <li key={i.slug}>
69 + <Link href={routes.industry(i.slug)} className="block py-3">
70 + <p className="text-[15px] font-medium text-ink">{i.name}</p>
71 + <p className="tnum mt-0.5 text-xs text-ink-3">
72 + {fmtInt(i.companies)} companies · {fmtInt(i.events_30d)} events 30 d · activity {fmtScore(i.activity_score)} · hiring {fmtPctSigned(i.hiring_momentum_30d, 0)}
73 + </p>
74 + <p className="mt-1 flex flex-wrap gap-1">
75 + {i.top_event_types.slice(0, 3).map((t) => (
76 + <EventTypeBadge key={t} type={t} small />
77 + ))}
78 + </p>
79 + </Link>
80 + </li>
81 + ))}
82 + </ul>
83 + </>
84 + )}
85 + </Container>
86 + );
87 +}
added apps/web/src/app/layout.tsx +62 −0
@@ -0,0 +1,62 @@
1 +import type { Metadata, Viewport } from 'next';
2 +import Script from 'next/script';
3 +import './globals.css';
4 +import { EventDrawer } from '@/components/events/event-drawer';
5 +import { EventDrawerProvider } from '@/components/events/event-drawer-context';
6 +import { MobileTabBar } from '@/components/layout/mobile-tab-bar';
7 +import { SearchProvider } from '@/components/layout/search-context';
8 +import { SearchDialog } from '@/components/layout/search-dialog';
9 +import { SiteFooter } from '@/components/layout/site-footer';
10 +import { SiteHeader } from '@/components/layout/site-header';
11 +import { fontMono, fontUi } from '@/lib/fonts';
12 +import { PREPAINT_SCRIPT } from '@/lib/prepaint';
13 +import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE, THEME_DARK, THEME_LIGHT } from '@/lib/site';
14 +
15 +export const metadata: Metadata = {
16 + metadataBase: new URL(SITE_URL),
17 + title: { default: `${SITE_NAME} — ${TAGLINE}`, template: `%s | ${SITE_NAME}` },
18 + description: DESCRIPTION,
19 + applicationName: SITE_NAME,
20 + robots: { index: true, follow: true },
21 + alternates: { canonical: '/' },
22 + openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
23 + twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
24 + icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }], apple: [{ url: '/apple-icon', sizes: '180x180', type: 'image/png' }] },
25 +};
26 +
27 +export const viewport: Viewport = {
28 + width: 'device-width',
29 + initialScale: 1,
30 + viewportFit: 'cover',
31 + themeColor: [
32 + { media: '(prefers-color-scheme: light)', color: THEME_LIGHT },
33 + { media: '(prefers-color-scheme: dark)', color: THEME_DARK },
34 + ],
35 +};
36 +
37 +export default function RootLayout({ children }: { children: React.ReactNode }) {
38 + return (
39 + <html lang="en" className={`${fontUi.variable} ${fontMono.variable} h-full antialiased`} suppressHydrationWarning>
40 + <head>
41 + <Script id="ca-prepaint" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: PREPAINT_SCRIPT }} />
42 + </head>
43 + <body className="flex min-h-full flex-col pb-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px))] lg:pb-0">
44 + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-accent-ink">
45 + Skip to content
46 + </a>
47 + <SearchProvider>
48 + <EventDrawerProvider>
49 + <SiteHeader />
50 + <main id="main" className="flex-1">
51 + {children}
52 + </main>
53 + <SiteFooter />
54 + <MobileTabBar />
55 + <SearchDialog />
56 + <EventDrawer />
57 + </EventDrawerProvider>
58 + </SearchProvider>
59 + </body>
60 + </html>
61 + );
62 +}
added apps/web/src/app/live/page.tsx +34 −0
@@ -0,0 +1,34 @@
1 +import type { Metadata } from 'next';
2 +import { Suspense } from 'react';
3 +import { EventFilters } from '@/components/events/event-filters';
4 +import { LiveFeed } from '@/components/live/live-feed';
5 +import { Container, PageHeader, Unavailable } from '@/components/ui/section';
6 +import { api, liveItems, safe } from '@/lib/api';
7 +import { str, type SP } from '@/lib/params';
8 +
9 +export const metadata: Metadata = { title: 'Live feed', description: 'Structured corporate events as they are detected across monitored public surfaces — products, pricing, hiring, leadership, locations and more.' };
10 +export const dynamic = 'force-dynamic';
11 +
12 +export default async function LivePage({ searchParams }: { searchParams: Promise<SP> }) {
13 + const sp = await searchParams;
14 + const q = { limit: 80, event_type: str(sp.event_type), min_importance: str(sp.min_importance), country: str(sp.country), industry: str(sp.industry), min_confidence: str(sp.min_confidence) };
15 + const [live, countries, industries] = await Promise.all([safe(api.live(q)), safe(api.countries()), safe(api.industries())]);
16 + const initial = liveItems(live);
17 + return (
18 + <Container>
19 + <PageHeader
20 + eyebrow={
21 + <>
22 + <span className="dot pulse" aria-hidden /> Live
23 + </>
24 + }
25 + title="Global live feed"
26 + lede="Every card is a structured event detected on a monitored public page, with its source, confidence label and evidence. The stream updates in place; hover or pause to read."
27 + />
28 + <Suspense>
29 + <EventFilters showSort={false} countries={(countries?.items ?? []).map((c) => ({ value: c.code, label: c.name }))} industries={(industries?.items ?? []).map((i) => ({ value: i.slug, label: i.name }))} className="mb-4" />
30 + {live ? <LiveFeed initial={initial} limit={80} /> : <Unavailable what="The live feed" />}
31 + </Suspense>
32 + </Container>
33 + );
34 +}
added apps/web/src/app/manifest.ts +31 −0
@@ -0,0 +1,31 @@
1 +import type { MetadataRoute } from 'next';
2 +import { DESCRIPTION, SITE_NAME, TAGLINE, THEME_DARK, THEME_LIGHT } from '@/lib/site';
3 +
4 +export default function manifest(): MetadataRoute.Manifest {
5 + return {
6 + name: `${SITE_NAME} — ${TAGLINE}`,
7 + short_name: SITE_NAME,
8 + description: DESCRIPTION,
9 + id: '/',
10 + start_url: '/',
11 + scope: '/',
12 + display: 'standalone',
13 + orientation: 'any',
14 + background_color: THEME_DARK,
15 + theme_color: THEME_LIGHT,
16 + lang: 'en',
17 + dir: 'ltr',
18 + categories: ['business', 'reference', 'news'],
19 + icons: [
20 + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
21 + { src: '/apple-icon', sizes: '180x180', type: 'image/png', purpose: 'any' },
22 + { src: '/icon-512', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
23 + ],
24 + shortcuts: [
25 + { name: 'Live feed', short_name: 'Live', description: 'Structured corporate events as they are detected', url: '/live' },
26 + { name: 'Companies', short_name: 'Companies', description: 'Monitored companies directory', url: '/companies' },
27 + { name: 'Rankings', short_name: 'Rankings', description: 'Most active, fastest hiring, most AI-active…', url: '/rankings' },
28 + { name: 'Watchlist', short_name: 'Watchlist', description: 'Companies you follow', url: '/watchlist' },
29 + ],
30 + };
31 +}
added apps/web/src/app/methodology/page.tsx +85 −0
@@ -0,0 +1,85 @@
1 +import type { Metadata } from 'next';
2 +import { ConfidenceBadge, EventTypeBadge, SignificanceBadge } from '@/components/ui/badges';
3 +import { Container, Note, PageHeader, Section, Unavailable } from '@/components/ui/section';
4 +import { api, safe } from '@/lib/api';
5 +import { METRIC_LABELS } from '@/lib/site';
6 +
7 +export const metadata: Metadata = { title: 'Methodology', description: 'How Company Atlas computes Activity Score, Hiring Momentum, Product Velocity, AI Adoption, the Corporate Change Index and significance bands — with formula versions and inputs.' };
8 +export const revalidate = 3600;
9 +
10 +export default async function MethodologyPage() {
11 + const m = await safe(api.methodology());
12 + const bands = Array.isArray(m?.significance_bands) ? m.significance_bands : m?.significance_bands ? Object.entries(m.significance_bands).map(([label, [min, max]]) => ({ label, min, max })) : [];
13 + const labels = m?.confidence_labels ? (Array.isArray(m.confidence_labels) ? m.confidence_labels.map((l) => [l, ''] as const) : Object.entries(m.confidence_labels)) : [];
14 + return (
15 + <Container>
16 + <PageHeader eyebrow="Methodology" title="Reproducible metrics over observed public pages" lede="Every metric value on the site carries a formula version, its inputs and a computation time. Formula versions bump whenever weights or normalisation change, so historical values remain reproducible." />
17 + {!m ? (
18 + <Unavailable what="Methodology" />
19 + ) : (
20 + <>
21 + <Section eyebrow="Metrics" title="Definitions" hairline={false}>
22 + <div className="divide-y divide-rule border-y border-rule">
23 + {m.metrics.map((x) => (
24 + <div key={x.metric} id={x.metric} className="scroll-mt-24 grid gap-2 py-4 md:grid-cols-[14rem_minmax(0,1fr)]">
25 + <div>
26 + <p className="font-medium text-ink">{METRIC_LABELS[x.metric] ?? x.metric}</p>
27 + <p className="mono text-[11px] text-ink-3">
28 + {x.metric} · {x.formula_version}
29 + </p>
30 + </div>
31 + <div>
32 + <p className="text-sm text-ink-2">{x.description}</p>
33 + {x.inputs.length > 0 && (
34 + <p className="mt-1.5 flex flex-wrap gap-1">
35 + {x.inputs.map((i) => (
36 + <span key={i} className="mono rounded-[3px] bg-surface-2 px-1 text-[11px] text-ink-2">
37 + {i}
38 + </span>
39 + ))}
40 + </p>
41 + )}
42 + </div>
43 + </div>
44 + ))}
45 + </div>
46 + </Section>
47 + <Section eyebrow="Change detection" title="Significance bands">
48 + <div className="grid gap-2 sm:grid-cols-5">
49 + {bands.map((b) => (
50 + <div key={b.label} className="border border-rule p-3">
51 + <SignificanceBadge value={(b.min + b.max) / 2} />
52 + <p className="tnum mt-1 text-sm text-ink-2">
53 + {b.min.toFixed(2)} – {b.max.toFixed(2)}
54 + </p>
55 + </div>
56 + ))}
57 + </div>
58 + <Note className="mt-3">Inputs: percentage of text changed, semantic similarity, page importance, affected structured entities (jobs, plans, people, locations), novelty against the sensor’s history and cross-source confirmation. Only changes above the meaningful band produce events; lower bands are archived, never discarded.</Note>
59 + </Section>
60 + <Section eyebrow="Confidence" title="Labels on every inferred value">
61 + <ul className="divide-y divide-rule border-y border-rule">
62 + {labels.map(([l, d]) => (
63 + <li key={l} className="flex flex-wrap items-baseline gap-3 py-2 text-sm">
64 + <ConfidenceBadge label={l} />
65 + <span className="text-ink-2">{d}</span>
66 + </li>
67 + ))}
68 + </ul>
69 + </Section>
70 + <Section eyebrow="Taxonomy" title="Event types">
71 + <p className="flex flex-wrap gap-1.5">
72 + {m.event_types.map((t) => (
73 + <EventTypeBadge key={t} type={t} />
74 + ))}
75 + </p>
76 + <Note className="mt-3">Subtypes include product launch / no longer listed / renamed, price increase / decrease / new tier, job count increase / decrease, new executive / executive no longer listed, new office / office no longer listed / country expansion, new partnership, acquisition, divestiture, API launch, documentation change, terms change and brand repositioning.</Note>
77 + </Section>
78 + <Section eyebrow="Coverage normalisation" title="Why long-term indices are adjusted">
79 + <p className="max-w-3xl text-sm text-ink-2">As the network grows, raw counts of observations and events rise regardless of what companies do. The Global Corporate Activity Index and industry/country series are normalised by the number of active sensors, the company population, crawl frequency and industry/country coverage in each window, so a rising index means more change per monitored surface — not more surfaces. Baselines are computed per sensor and per company (changes per week, job counts, announcement frequency, volatility) and drive the anomaly score.</p>
80 + </Section>
81 + </>
82 + )}
83 + </Container>
84 + );
85 +}
added apps/web/src/app/not-found.tsx +45 −0
@@ -0,0 +1,45 @@
1 +import { Search } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { Container, Note } from '@/components/ui/section';
5 +import { PUBLIC_API_BASE, routes } from '@/lib/site';
6 +
7 +export const metadata: Metadata = { title: 'Not found', robots: { index: false } };
8 +
9 +/** 404 for the whole site and for company/event/sensor pages whose id does not resolve. */
10 +export default function NotFound() {
11 + return (
12 + <Container className="py-16 md:py-24">
13 + <p className="eyebrow">404 · Not on the map</p>
14 + <h1 className="display mt-2 text-3xl md:text-5xl">No monitored record at this address.</h1>
15 + <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-ink-2">The company may not be onboarded yet, the slug may have changed (ids never do), or the page never existed. Nothing is invented to fill the gap — search the atlas instead.</p>
16 + <form action="/search" method="get" role="search" className="mt-6 flex max-w-xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">
17 + <label htmlFor="nf-q" className="sr-only">
18 + Search Company Atlas
19 + </label>
20 + <Search className="my-auto ml-3 size-4 shrink-0 text-ink-3" aria-hidden />
21 + <input id="nf-q" name="q" type="search" placeholder="Search companies, industries, countries…" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} />
22 + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">
23 + Search
24 + </button>
25 + </form>
26 + <div className="mt-6 flex flex-wrap gap-2 text-sm">
27 + <Link href={routes.home()} className="btn">
28 + Home
29 + </Link>
30 + <Link href={routes.companies()} className="btn">
31 + Companies
32 + </Link>
33 + <Link href={routes.live()} className="btn">
34 + Live feed
35 + </Link>
36 + <Link href={routes.events()} className="btn">
37 + Events
38 + </Link>
39 + </div>
40 + <Note className="mt-8 max-w-xl">
41 + Canonical URLs: <span className="mono">/company/&lt;slug&gt;</span>, <span className="mono">/industry/&lt;slug&gt;</span>, <span className="mono">/country/&lt;code&gt;</span>, <span className="mono">/events/&lt;id&gt;</span>. Programmatic lookup: <span className="mono">{PUBLIC_API_BASE.replace(/^https?:\/\/www\./, '')}/search/suggest?q=…</span>
42 + </Note>
43 + </Container>
44 + );
45 +}
added apps/web/src/app/opengraph-image.tsx +60 −0
@@ -0,0 +1,60 @@
1 +import { ImageResponse } from 'next/og';
2 +import { MARK_DARK, MarkImg } from '@/components/brand/mark';
3 +import { api, safe } from '@/lib/api';
4 +import { fmtInt } from '@/lib/format';
5 +import { SITE_NAME, TAGLINE } from '@/lib/site';
6 +
7 +export const runtime = 'nodejs';
8 +export const alt = `${SITE_NAME} — ${TAGLINE}`;
9 +export const size = { width: 1200, height: 630 };
10 +export const contentType = 'image/png';
11 +
12 +/** Root Open Graph image: mark + name + tagline + live counters from /stats (brand-only when the API is down). */
13 +export default async function OpenGraphImage() {
14 + const stats = await safe(api.stats());
15 + const counters: [string, string][] = stats
16 + ? [
17 + ['Companies', fmtInt(stats.companies)],
18 + ['Sensors', fmtInt(stats.sensors)],
19 + ['Observations', fmtInt(stats.observations)],
20 + ['Structured events', fmtInt(stats.events)],
21 + ]
22 + : [];
23 + return new ImageResponse(
24 + (
25 + <div style={{ width: 1200, height: 630, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: '#0a0d12', color: '#e8ebf1', fontFamily: 'Helvetica, Arial, sans-serif' }}>
26 + <div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(to right, rgba(190,200,225,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(190,200,225,0.08) 1px, transparent 1px)', backgroundSize: '48px 48px' }} />
27 + <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
28 + <MarkImg px={64} colors={MARK_DARK} />
29 + <div style={{ display: 'flex', flexDirection: 'column' }}>
30 + <div style={{ display: 'flex', gap: 8, fontSize: 30, letterSpacing: -0.5 }}>
31 + <span style={{ color: '#a3a9b8' }}>Company</span>
32 + <span style={{ fontWeight: 700 }}>Atlas</span>
33 + </div>
34 + <div style={{ fontSize: 15, color: '#6f7688', letterSpacing: 2, textTransform: 'uppercase' }}>Continuous corporate observation network</div>
35 + </div>
36 + <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 10, color: '#3fd07a', fontSize: 18 }}>
37 + <div style={{ width: 12, height: 12, borderRadius: 12, background: '#3fd07a' }} /> LIVE
38 + </div>
39 + </div>
40 + <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
41 + <div style={{ fontSize: 66, fontWeight: 700, letterSpacing: -2.5, lineHeight: 1.02 }}>{TAGLINE}</div>
42 + <div style={{ fontSize: 24, color: '#a3a9b8', maxWidth: 980, lineHeight: 1.35 }}>Company Atlas continuously observes the public web to track how companies evolve — products, hiring, pricing, leadership, locations, technology and strategy.</div>
43 + </div>
44 + <div style={{ display: 'flex', gap: 48, borderTop: '1px solid rgba(190,200,225,0.2)', paddingTop: 24 }}>
45 + {counters.length ? (
46 + counters.map(([label, value]) => (
47 + <div key={label} style={{ display: 'flex', flexDirection: 'column' }}>
48 + <div style={{ fontSize: 13, letterSpacing: 2, textTransform: 'uppercase', color: '#6f7688' }}>{label}</div>
49 + <div style={{ fontSize: 40, fontWeight: 600, letterSpacing: -1, fontFamily: 'Menlo, monospace' }}>{value}</div>
50 + </div>
51 + ))
52 + ) : (
53 + <div style={{ fontSize: 18, color: '#6f7688' }}>www.company-atlas.co</div>
54 + )}
55 + </div>
56 + </div>
57 + ),
58 + { ...size },
59 + );
60 +}
added apps/web/src/app/page.tsx +254 −0
@@ -0,0 +1,254 @@
1 +import { ArrowRight, Code2 } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { Suspense } from 'react';
4 +import { Sparkline } from '@/components/charts/sparkline';
5 +import { WorldMap } from '@/components/charts/world-map';
6 +import { CompanyMiniList } from '@/components/company/company-table';
7 +import { EventList } from '@/components/events/event-row';
8 +import { LiveCounters } from '@/components/live/counters';
9 +import { LiveFeed } from '@/components/live/live-feed';
10 +import { Chip, CountryChip } from '@/components/ui/badges';
11 +import { Container, Empty, Note, Section, Stat, StatGrid, Unavailable } from '@/components/ui/section';
12 +import { api, liveItems, safe } from '@/lib/api';
13 +import { countryName } from '@/lib/countries';
14 +import { fmtBytes, fmtDate, fmtDays, fmtInt, fmtPctSigned, fmtScore } from '@/lib/format';
15 +import { ALT_TAGLINE, DESCRIPTION, PUBLIC_API_BASE, routes, TAGLINE } from '@/lib/site';
16 +import type { Event, Pulse } from '@/lib/types';
17 +
18 +export const revalidate = 60;
19 +
20 +export default async function HomePage() {
21 + const [pulse, live, index, signals] = await Promise.all([safe(api.pulse()), safe(api.live({ limit: 40 })), safe(api.index()), safe(api.signals({ limit: 8 }))]);
22 + const initialLive: Event[] = liveItems(live).length ? liveItems(live) : (pulse?.live ?? []);
23 + const stats = pulse?.stats ?? (await safe(api.stats()));
24 + const p: Partial<Pulse> = pulse ?? {};
25 +
26 + return (
27 + <>
28 + {/* Hero */}
29 + <div className="relative overflow-hidden border-b border-rule">
30 + <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden />
31 + <Container className="relative pb-8 pt-10 md:pb-12 md:pt-16">
32 + <p className="eyebrow flex items-center gap-2">
33 + <span className="dot pulse" aria-hidden /> Continuous corporate observation network
34 + </p>
35 + <h1 className="display mt-3 max-w-4xl text-[34px] md:text-[60px]">{TAGLINE}</h1>
36 + <p className="mt-4 max-w-2xl text-[16px] leading-relaxed text-ink-2 md:text-[18px]">{DESCRIPTION}</p>
37 + <p className="mt-2 max-w-2xl text-sm text-ink-3">{ALT_TAGLINE}</p>
38 + <div className="mt-6 flex flex-wrap gap-2">
39 + <Link href={routes.live()} className="btn btn-primary">
40 + Open the live feed <ArrowRight className="size-4" aria-hidden />
41 + </Link>
42 + <Link href={routes.companies()} className="btn">
43 + Browse companies
44 + </Link>
45 + <Link href={routes.api()} className="btn">
46 + <Code2 className="size-4" aria-hidden /> API & data
47 + </Link>
48 + </div>
49 + <LiveCounters stats={stats} className="mt-8 md:mt-10" />
50 + </Container>
51 + </div>
52 +
53 + <Container>
54 + {/* Live feed + movers */}
55 + <div className="grid gap-8 lg:grid-cols-12">
56 + <Section eyebrow="Live activity feed" title="What changed in the last minutes" action={{ href: routes.live(), label: 'Full feed' }} className="lg:col-span-7" hairline={false}>
57 + {initialLive.length ? (
58 + <Suspense fallback={<EventList events={initialLive.slice(0, 18)} variant="feed" />}>
59 + <LiveFeed initial={initialLive} limit={18} compact showControls={false} />
60 + </Suspense>
61 + ) : (
62 + <Unavailable what="The live feed" />
63 + )}
64 + </Section>
65 + <Section eyebrow="Companies moving fastest" title="Highest Corporate Change Index" action={{ href: routes.rankings('most_active', '7d'), label: 'Rankings' }} className="lg:col-span-5" hairline={false}>
66 + {p.movers ? <CompanyMiniList items={p.movers} metric="corporate_change_index" label="CCI" sparkline /> : <Unavailable what="Movers" compact />}
67 + <Note className="mt-3">CCI = 0.25 hiring + 0.20 product + 0.15 geographic + 0.15 leadership + 0.10 developer + 0.10 communication + 0.05 pricing (provisional weights, see methodology).</Note>
68 + </Section>
69 + </div>
70 +
71 + {/* Map */}
72 + <Section eyebrow="Global activity map" title="Where monitored companies are changing" lede="Bubbles are sized by structured events detected in the last 30 days, clustered by headquarters city. Hover for detail, click to open the country atlas." action={{ href: routes.countries(), label: 'Country atlas' }}>
73 + {p.map && p.map.length ? (
74 + <div className="grid gap-6 lg:grid-cols-[minmax(0,2.2fr)_minmax(0,1fr)]">
75 + <WorldMap buckets={p.map} />
76 + <div>
77 + <p className="eyebrow mb-1">Most active countries (30 d)</p>
78 + <ol className="divide-y divide-rule border-y border-rule text-sm">
79 + {(p.countries ?? []).slice(0, 8).map((c) => (
80 + <li key={c.code}>
81 + <Link href={routes.country(c.code)} className="flex items-center gap-2 py-1.5 hover:text-accent">
82 + <CountryChip code={c.code} link={false} />
83 + <span className="min-w-0 flex-1 truncate">{c.name}</span>
84 + <span className="tnum text-xs text-ink-3">{fmtInt(c.companies)} co.</span>
85 + <span className="tnum w-12 text-right font-medium">{fmtInt(c.events_30d)}</span>
86 + </Link>
87 + </li>
88 + ))}
89 + </ol>
90 + </div>
91 + </div>
92 + ) : (
93 + <Unavailable what="The activity map" />
94 + )}
95 + </Section>
96 +
97 + {/* Hiring / launches / pricing */}
98 + <div className="grid gap-8 md:grid-cols-3">
99 + <Section eyebrow="Hiring momentum" title="Open listings, 30-day change" action={{ href: routes.rankings('hiring_growth', '30d'), label: 'All' }} className="md:border-t">
100 + {p.hiring ? <CompanyMiniList items={p.hiring} metric="hiring_momentum_30d" /> : <Unavailable what="Hiring momentum" compact />}
101 + </Section>
102 + <Section eyebrow="Product launches" title="New products detected" action={{ href: routes.events({ event_type: 'PRODUCT' }), label: 'All' }}>
103 + {p.launches ? <EventList events={p.launches.slice(0, 6)} variant="feed" emptyLabel="No product launches detected in the current window." /> : <Unavailable what="Launches" compact />}
104 + </Section>
105 + <Section eyebrow="Pricing changes" title="Public pricing pages that changed" action={{ href: routes.events({ event_type: 'PRICING' }), label: 'All' }}>
106 + {p.pricing ? <EventList events={p.pricing.slice(0, 6)} variant="feed" emptyLabel="No pricing changes detected in the current window." /> : <Unavailable what="Pricing changes" compact />}
107 + </Section>
108 + </div>
109 +
110 + {/* AI / industries / countries */}
111 + <div className="grid gap-8 md:grid-cols-3">
112 + <Section eyebrow="AI adoption" title="Observable public AI signals" action={{ href: routes.rankings('ai_active', '30d'), label: 'All' }}>
113 + {p.ai ? <CompanyMiniList items={p.ai} metric="ai_adoption" label="score" /> : <Unavailable what="AI adoption" compact />}
114 + <Note className="mt-2">Scored from public evidence only (AI listings, products, documentation, communications). Never a claim about internal use.</Note>
115 + </Section>
116 + <Section eyebrow="Industries" title="Industry atlas" action={{ href: routes.industries(), label: 'All industries' }}>
117 + {p.industries ? (
118 + <ol className="divide-y divide-rule border-y border-rule text-sm">
119 + {p.industries.slice(0, 10).map((i) => (
120 + <li key={i.slug}>
121 + <Link href={routes.industry(i.slug)} className="flex items-center gap-2 py-1.5 hover:text-accent">
122 + <span className="min-w-0 flex-1 truncate">{i.name}</span>
123 + <span className="tnum text-xs text-ink-3">{fmtInt(i.companies)} co.</span>
124 + <span className="tnum w-14 text-right text-xs">{fmtScore(i.activity_score)}</span>
125 + <span className="tnum w-12 text-right font-medium">{fmtInt(i.events_30d)}</span>
126 + </Link>
127 + </li>
128 + ))}
129 + </ol>
130 + ) : (
131 + <Unavailable what="Industries" compact />
132 + )}
133 + <p className="mt-1 text-right text-[10px] uppercase tracking-wider text-ink-3">activity · events 30 d</p>
134 + </Section>
135 + <Section eyebrow="Countries" title="Country atlas" action={{ href: routes.countries(), label: 'All countries' }}>
136 + {p.countries ? (
137 + <ol className="divide-y divide-rule border-y border-rule text-sm">
138 + {p.countries.slice(0, 10).map((c) => (
139 + <li key={c.code}>
140 + <Link href={routes.country(c.code)} className="flex items-center gap-2 py-1.5 hover:text-accent">
141 + <CountryChip code={c.code} link={false} />
142 + <span className="min-w-0 flex-1 truncate">{c.name}</span>
143 + <span className={`tnum w-16 text-right text-xs ${(c.hiring_momentum_30d ?? 0) > 0 ? 'text-positive' : (c.hiring_momentum_30d ?? 0) < 0 ? 'text-danger' : 'text-ink-3'}`}>{fmtPctSigned(c.hiring_momentum_30d, 0)}</span>
144 + <span className="tnum w-12 text-right font-medium">{fmtInt(c.events_30d)}</span>
145 + </Link>
146 + </li>
147 + ))}
148 + </ol>
149 + ) : (
150 + <Unavailable what="Countries" compact />
151 + )}
152 + <p className="mt-1 text-right text-[10px] uppercase tracking-wider text-ink-3">hiring 30 d · events 30 d</p>
153 + </Section>
154 + </div>
155 +
156 + {/* Trending + index */}
157 + <div className="grid gap-8 lg:grid-cols-12">
158 + <Section eyebrow="Trending signals" title="Terms and patterns gaining momentum" action={{ href: routes.events(), label: 'Events' }} className="lg:col-span-7">
159 + <div className="grid gap-6 md:grid-cols-2">
160 + <div>
161 + <p className="eyebrow mb-1">Trending terms (7 d)</p>
162 + {p.trending?.length ? (
163 + <ul className="divide-y divide-rule border-y border-rule text-sm">
164 + {p.trending.slice(0, 8).map((t) => (
165 + <li key={t.term} className="flex items-center gap-2 py-1.5">
166 + <Link href={routes.search(t.term)} className="min-w-0 flex-1 truncate hover:text-accent">
167 + {t.term}
168 + </Link>
169 + <Sparkline values={t.series} width={56} height={16} />
170 + <span className="tnum text-xs text-ink-3">{fmtInt(t.companies)} co.</span>
171 + <span className={`tnum w-14 text-right text-xs font-medium ${(t.momentum ?? 0) > 0 ? 'text-positive' : 'text-ink-3'}`}>{t.momentum === null ? '—' : fmtPctSigned(t.momentum, 0)}</span>
172 + </li>
173 + ))}
174 + </ul>
175 + ) : (
176 + <Empty compact />
177 + )}
178 + </div>
179 + <div>
180 + <p className="eyebrow mb-1">Cross-company signals</p>
181 + {signals?.items.length ? (
182 + <ul className="divide-y divide-rule border-y border-rule text-sm">
183 + {signals.items.slice(0, 6).map((s) => (
184 + <li key={s.id} className="py-1.5">
185 + <div className="flex items-center gap-2">
186 + <Chip tone="accent">signal</Chip>
187 + <span className="min-w-0 flex-1 truncate text-ink">{s.title}</span>
188 + <span className="tnum text-xs text-ink-3">{Math.round(s.confidence * 100)} %</span>
189 + </div>
190 + {s.scope_key && <p className="text-[11px] text-ink-3">{s.scope} · {s.scope === 'country' ? countryName(s.scope_key) : s.scope_key}</p>}
191 + </li>
192 + ))}
193 + </ul>
194 + ) : (
195 + <Empty compact title="No cross-company signals detected yet." />
196 + )}
197 + </div>
198 + </div>
199 + </Section>
200 + <Section eyebrow="Global Corporate Activity Index" title="Baseline 100 = normalised historical activity" action={{ href: routes.methodology(), label: 'Methodology' }} className="lg:col-span-5">
201 + {p.activity_index ? (
202 + <div>
203 + <div className="flex items-end gap-4">
204 + <p className="tnum text-[44px] font-semibold leading-none tracking-tight">{p.activity_index.value === null ? '—' : p.activity_index.value.toFixed(1)}</p>
205 + <div className="pb-1 text-xs text-ink-3">
206 + <p className={`tnum font-medium ${(p.activity_index.delta_7d ?? 0) > 0 ? 'text-positive' : (p.activity_index.delta_7d ?? 0) < 0 ? 'text-danger' : ''}`}>{fmtPctSigned(p.activity_index.delta_7d)} 7 d</p>
207 + {index && <p className={`tnum ${(index.delta_30d ?? 0) > 0 ? 'text-positive' : (index.delta_30d ?? 0) < 0 ? 'text-danger' : ''}`}>{fmtPctSigned(index.delta_30d)} 30 d</p>}
208 + </div>
209 + </div>
210 + <Sparkline values={(index?.series ?? p.activity_index.series).slice(-90).map((x) => x.value)} width={420} height={72} tone="accent" baseline={100} className="mt-3 h-auto w-full" />
211 + <p className="mt-1 text-[11px] text-ink-3">Last 90 days · coverage-normalised · {index?.formula_version ?? 'v1'}</p>
212 + </div>
213 + ) : (
214 + <Unavailable what="The index" compact />
215 + )}
216 + </Section>
217 + </div>
218 +
219 + {/* Platform statistics + API CTA */}
220 + <Section eyebrow="Platform statistics" title="The dataset gets harder to reproduce every day">
221 + {stats ? (
222 + <StatGrid cols={6}>
223 + <Stat label="Dataset age" value={fmtDays(stats.dataset_age_days)} hint={stats.dataset_started_at ? `since ${fmtDate(stats.dataset_started_at)}` : undefined} size="sm" />
224 + <Stat label="Oldest continuous history" value={fmtDays(stats.oldest_history_days)} size="sm" />
225 + <Stat label="Observations today" value={fmtInt(stats.observations_today)} size="sm" />
226 + <Stat label="Changes today" value={fmtInt(stats.changes_today)} hint={`${fmtInt(stats.events_today)} events`} size="sm" />
227 + <Stat label="Countries · industries" value={`${fmtInt(stats.countries)} · ${fmtInt(stats.industries)}`} size="sm" />
228 + <Stat label="Archive" value={fmtBytes(stats.archive?.bytes)} hint={`${fmtInt(stats.archive?.objects)} content objects`} size="sm" />
229 + </StatGrid>
230 + ) : (
231 + <Unavailable what="Platform statistics" />
232 + )}
233 + <div className="mt-6 grid gap-4 border border-rule p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:p-6">
234 + <div>
235 + <p className="eyebrow">API & data</p>
236 + <p className="mt-1 text-lg font-semibold tracking-tight">Every event, metric and history version is available through a public JSON API.</p>
237 + <p className="mt-1 text-sm text-ink-2">
238 + Companies, events with filters, timelines, rankings, industries, countries, search, streaming (SSE) and exports (JSON, NDJSON, CSV). <span className="mono text-xs text-ink-3">{PUBLIC_API_BASE}</span>
239 + </p>
240 + </div>
241 + <div className="flex flex-wrap gap-2">
242 + <Link href={routes.api()} className="btn btn-primary">
243 + Read the API docs
244 + </Link>
245 + <Link href={routes.methodology()} className="btn">
246 + Methodology
247 + </Link>
248 + </div>
249 + </div>
250 + </Section>
251 + </Container>
252 + </>
253 + );
254 +}
added apps/web/src/app/rankings/page.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import type { Metadata } from 'next';
2 +import { RankingTabs, RankingsTable } from '@/components/rankings/rankings-table';
3 +import { Container, Note, PageHeader, Unavailable } from '@/components/ui/section';
4 +import { api, safe } from '@/lib/api';
5 +import { str, type SP } from '@/lib/params';
6 +import { RANKING_KINDS } from '@/lib/site';
7 +
8 +export const metadata: Metadata = { title: 'Rankings', description: 'Most active companies, fastest hiring growth and decline, product velocity, AI activity, geographic expansion, developer momentum, pricing changes and unusual activity — by window and region.' };
9 +export const revalidate = 120;
10 +
11 +export default async function RankingsPage({ searchParams }: { searchParams: Promise<SP> }) {
12 + const sp = await searchParams;
13 + const kind = RANKING_KINDS.some((k) => k.id === str(sp.kind)) ? (str(sp.kind) as string) : 'most_active';
14 + const window = ['24h', '7d', '30d', '90d', '1y'].includes(str(sp.window) ?? '') ? (str(sp.window) as string) : '30d';
15 + const country = str(sp.country);
16 + const industry = str(sp.industry);
17 + const [data, countries, industries] = await Promise.all([safe(api.rankings({ kind, window, country, industry, limit: 50 })), safe(api.countries()), safe(api.industries())]);
18 + const k = RANKING_KINDS.find((x) => x.id === kind)!;
19 + return (
20 + <Container wide>
21 + <PageHeader eyebrow="Rankings" title={k.label} lede="Rankings are computed from monitored public surfaces and normalised by coverage. Windows compare the same company against its own baseline; deltas show the move since the previous window." />
22 + <RankingTabs kind={kind} window={window} country={country} industry={industry} />
23 + <form method="get" action="/rankings" className="mt-3 flex flex-wrap gap-2">
24 + <input type="hidden" name="kind" value={kind} />
25 + <input type="hidden" name="window" value={window} />
26 + <select name="country" defaultValue={country ?? ''} className="field h-9 text-xs" aria-label="Country">
27 + <option value="">All countries</option>
28 + {(countries?.items ?? []).map((c) => (
29 + <option key={c.code} value={c.code}>
30 + {c.name}
31 + </option>
32 + ))}
33 + </select>
34 + <select name="industry" defaultValue={industry ?? ''} className="field h-9 text-xs" aria-label="Industry">
35 + <option value="">All industries</option>
36 + {(industries?.items ?? []).map((i) => (
37 + <option key={i.slug} value={i.slug}>
38 + {i.name}
39 + </option>
40 + ))}
41 + </select>
42 + <button type="submit" className="btn btn-sm">
43 + Apply
44 + </button>
45 + </form>
46 + <div className="mt-4">{data ? <RankingsTable data={data} /> : <Unavailable what="Rankings" />}</div>
47 + <Note className="mt-4">{k.unit === 'pct' ? 'Hiring momentum is the percentage change in publicly listed open roles; a decline in listings is not evidence of layoffs.' : k.unit === 'count' ? 'Counts structured pricing events detected on public pricing pages in the window.' : 'Scores are 0–100 relative to the monitored population; see methodology for formula versions.'}</Note>
48 + </Container>
49 + );
50 +}
added apps/web/src/app/robots.ts +10 −0
@@ -0,0 +1,10 @@
1 +import type { MetadataRoute } from 'next';
2 +import { SITE_URL } from '@/lib/site';
3 +
4 +export default function robots(): MetadataRoute.Robots {
5 + return {
6 + rules: [{ userAgent: '*', allow: '/', disallow: ['/api/v1/', '/admin', '/admin/', '/search', '/watchlist', '/snapshot/', '/change/', '/sensor/'] }],
7 + sitemap: `${SITE_URL}/sitemap.xml`,
8 + host: SITE_URL,
9 + };
10 +}
added apps/web/src/app/search/page.tsx +177 −0
@@ -0,0 +1,177 @@
1 +import { Search as SearchIcon, Sparkles } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { CompanyTable } from '@/components/company/company-table';
5 +import { EventList } from '@/components/events/event-row';
6 +import { Chip, CountryChip } from '@/components/ui/badges';
7 +import { Container, Empty, Note, PageHeader, Section } from '@/components/ui/section';
8 +import { api, safe } from '@/lib/api';
9 +import { fmtDate, fmtInt, fmtScore, pathOf } from '@/lib/format';
10 +import { str, type SP } from '@/lib/params';
11 +import { EXAMPLE_QUERIES, routes } from '@/lib/site';
12 +
13 +export const metadata: Metadata = { title: 'Search', robots: { index: false } };
14 +export const dynamic = 'force-dynamic';
15 +
16 +function looksLikeQuestion(q: string): boolean {
17 + const s = q.trim().toLowerCase();
18 + const words = s.split(/\s+/).filter(Boolean);
19 + if (words.length >= 4) return true;
20 + return /^(which|what|who|where|how|show|list|find)\b/.test(s) || /\b(hiring|in|with|that|expanding|launch|pricing)\b/.test(s) && words.length >= 3;
21 +}
22 +
23 +export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) {
24 + const sp = await searchParams;
25 + const q = (str(sp.q) ?? '').trim();
26 + const ask = q && looksLikeQuestion(q);
27 + const [res, answer] = await Promise.all([q ? safe(api.search(q, { limit: 10 })) : Promise.resolve(null), ask ? safe(api.ask(q)) : Promise.resolve(null)]);
28 + const total = res ? res.companies.length + res.events.length + res.industries.length + res.countries.length + res.people.length + res.products.length : 0;
29 + return (
30 + <Container wide>
31 + <PageHeader eyebrow="Search" title={q ? <>Results for “{q}”</> : 'Search the atlas'} lede={q && res ? `${fmtInt(total)} results in ${res.took_ms} ms across companies, events, industries, countries, people and products.` : 'Companies, industries, countries, events, people, products — or ask a question in plain language.'} />
32 + <form method="get" action="/search" role="search" className="mb-6 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">
33 + <label htmlFor="q" className="sr-only">
34 + Search
35 + </label>
36 + <SearchIcon className="my-auto ml-3 size-4 shrink-0 text-ink-3" aria-hidden />
37 + <input id="q" name="q" type="search" defaultValue={q} placeholder="e.g. companies hiring AI engineers in Canada" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} />
38 + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">
39 + Search
40 + </button>
41 + </form>
42 + {!q && (
43 + <div>
44 + <p className="eyebrow mb-2">Try asking</p>
45 + <ul className="flex flex-wrap gap-2">
46 + {EXAMPLE_QUERIES.map((ex) => (
47 + <li key={ex}>
48 + <Link href={routes.search(ex)} className="chip-btn">
49 + {ex}
50 + </Link>
51 + </li>
52 + ))}
53 + </ul>
54 + <Note className="mt-4">Natural-language questions are parsed into structured filters (country, industry, event type, AI-related) and answered from the monitored record; every answer links back to events and their sources.</Note>
55 + </div>
56 + )}
57 + {q && ask && (
58 + <section className="mb-8 border border-accent/40 bg-accent-soft/40 p-4 md:p-5" data-ask-panel>
59 + <p className="eyebrow flex items-center gap-1.5 text-accent">
60 + <Sparkles className="size-3.5" aria-hidden /> Ask Company Atlas
61 + </p>
62 + {answer ? (
63 + <>
64 + <p className="mt-2 max-w-3xl text-[15px] leading-relaxed text-ink">{answer.answer}</p>
65 + {Object.keys(answer.interpretation ?? {}).length > 0 && (
66 + <p className="mt-2 flex flex-wrap items-center gap-1 text-xs text-ink-3">
67 + Interpreted as:
68 + {Object.entries(answer.interpretation).map(([k, v]) => (
69 + <Chip key={k} tone="outline">
70 + {k} = {String(v)}
71 + </Chip>
72 + ))}
73 + </p>
74 + )}
75 + {answer.companies.length > 0 && (
76 + <div className="mt-4">
77 + <p className="eyebrow mb-1">Matching companies</p>
78 + <CompanyTable items={answer.companies} showSparkline={false} />
79 + </div>
80 + )}
81 + {answer.events.length > 0 && (
82 + <div className="mt-4">
83 + <p className="eyebrow mb-1">Related events</p>
84 + <EventList events={answer.events} variant="table" />
85 + </div>
86 + )}
87 + {answer.sources.length > 0 && (
88 + <p className="mt-3 text-xs text-ink-3">
89 + Sources:{' '}
90 + {answer.sources.slice(0, 6).map((s, i) => (
91 + <span key={s + i}>
92 + {i > 0 && ' · '}
93 + <a href={s} target="_blank" rel="noopener noreferrer" className="link">
94 + {pathOf(s)}
95 + </a>
96 + </span>
97 + ))}
98 + </p>
99 + )}
100 + </>
101 + ) : (
102 + <p className="mt-2 text-sm text-ink-3">The question router did not answer in time; the keyword results below still apply.</p>
103 + )}
104 + </section>
105 + )}
106 + {q && !res && <Empty title="Search is temporarily unavailable." />}
107 + {q && res && total === 0 && !answer && <Empty title="No monitored evidence matches this query yet.">Try a company name, a domain, an industry or a country.</Empty>}
108 + {res && res.companies.length > 0 && (
109 + <Section eyebrow="Companies" title={`${res.companies.length} ${res.companies.length === 1 ? 'company' : 'companies'}`} hairline={false}>
110 + <CompanyTable items={res.companies} />
111 + </Section>
112 + )}
113 + {res && (res.industries.length > 0 || res.countries.length > 0) && (
114 + <Section eyebrow="Atlas" title="Industries and countries">
115 + <ul className="flex flex-wrap gap-2">
116 + {res.industries.map((i) => (
117 + <li key={i.slug}>
118 + <Link href={routes.industry(i.slug)} className="chip-btn">
119 + {i.name} <span className="tnum text-ink-3">{fmtInt(i.companies)}</span>
120 + </Link>
121 + </li>
122 + ))}
123 + {res.countries.map((c) => (
124 + <li key={c.code}>
125 + <Link href={routes.country(c.code)} className="chip-btn">
126 + <CountryChip code={c.code} link={false} /> {c.name} <span className="tnum text-ink-3">{fmtInt(c.companies)}</span>
127 + </Link>
128 + </li>
129 + ))}
130 + </ul>
131 + </Section>
132 + )}
133 + {res && res.events.length > 0 && (
134 + <Section eyebrow="Events" title={`${res.events.length} events`} action={{ href: routes.events({ q }), label: 'All matching events' }}>
135 + <EventList events={res.events} variant="table" />
136 + </Section>
137 + )}
138 + {res && (res.people.length > 0 || res.products.length > 0) && (
139 + <div className="grid gap-8 md:grid-cols-2">
140 + {res.people.length > 0 && (
141 + <Section eyebrow="People" title="Listed on monitored leadership pages">
142 + <ul className="divide-y divide-rule border-y border-rule text-sm">
143 + {res.people.map((p) => (
144 + <li key={p.id} className="flex flex-wrap items-center gap-2 py-2">
145 + <span className="font-medium text-ink">{p.name}</span>
146 + <span className="text-ink-2">{p.title}</span>
147 + <Link href={routes.company(p.company.slug, 'leadership')} className="link ml-auto text-xs">
148 + {p.company.display_name}
149 + </Link>
150 + <span className="text-[11px] text-ink-3">{p.status === 'listed' ? 'listed' : 'no longer listed'}</span>
151 + </li>
152 + ))}
153 + </ul>
154 + </Section>
155 + )}
156 + {res.products.length > 0 && (
157 + <Section eyebrow="Products" title="Listed in monitored catalogs">
158 + <ul className="divide-y divide-rule border-y border-rule text-sm">
159 + {res.products.map((p) => (
160 + <li key={p.id} className="flex flex-wrap items-center gap-2 py-2">
161 + <span className="font-medium text-ink">{p.name}</span>
162 + {p.category && <Chip>{p.category}</Chip>}
163 + <Link href={routes.company(p.company.slug, 'products')} className="link ml-auto text-xs">
164 + {p.company.display_name}
165 + </Link>
166 + <span className="text-[11px] text-ink-3">first seen {fmtDate(p.first_seen_at)}</span>
167 + </li>
168 + ))}
169 + </ul>
170 + </Section>
171 + )}
172 + </div>
173 + )}
174 + {res && res.companies.length > 0 && <p className="sr-only">{res.companies.map((c) => `${c.display_name} ${fmtScore(c.metrics.activity_score)}`).join(', ')}</p>}
175 + </Container>
176 + );
177 +}
added apps/web/src/app/sensor/[id]/page.tsx +164 −0
@@ -0,0 +1,164 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { notFound } from 'next/navigation';
5 +import { SensorTierBadge, SignificanceBadge, StatusBadge } from '@/components/ui/badges';
6 +import { KV, Row } from '@/components/ui/key-value';
7 +import { LiveAgo } from '@/components/ui/live';
8 +import { Container, Empty, Note, Stat, StatGrid } from '@/components/ui/section';
9 +import { api, ApiError, safe } from '@/lib/api';
10 +import { fmtDateTime, fmtDuration, fmtInt, fmtScore } from '@/lib/format';
11 +import { routes, SENSOR_TIER_LABELS, SURFACE_LABELS } from '@/lib/site';
12 +
13 +export const revalidate = 60;
14 +export const metadata: Metadata = { title: 'Sensor', robots: { index: false } };
15 +
16 +export default async function SensorPage({ params }: { params: Promise<{ id: string }> }) {
17 + const { id } = await params;
18 + let s;
19 + try {
20 + s = await api.sensor(id);
21 + } catch (e) {
22 + if (e instanceof ApiError && e.notFound) notFound();
23 + throw e;
24 + }
25 + const [snaps, changes] = await Promise.all([safe(api.sensorSnapshots(id, 50)), safe(api.sensorChanges(id, 50))]);
26 + const stale = s.last_success_at && Date.now() - new Date(s.last_success_at).getTime() > 2 * 86_400_000;
27 + return (
28 + <Container>
29 + <div className="pb-4 pt-6 md:pt-10">
30 + <p className="eyebrow flex flex-wrap items-center gap-2">
31 + Sensor <StatusBadge status={s.status} /> <SensorTierBadge tier={s.tier} withLabel />
32 + </p>
33 + <h1 className="display mt-2 text-[24px] md:text-[32px]">
34 + <Link href={routes.company(s.company.slug)} className="hover:text-accent">
35 + {s.company.display_name}
36 + </Link>{' '}
37 + <span className="text-ink-3">·</span> {SURFACE_LABELS[s.surface] ?? s.surface}
38 + </h1>
39 + <p className="mt-2 text-sm">
40 + <a href={s.url} target="_blank" rel="noopener noreferrer" className="link mono inline-flex items-center gap-1 break-all text-xs">
41 + {s.url} <ExternalLink className="size-3 shrink-0" aria-hidden />
42 + </a>
43 + </p>
44 + {stale && (
45 + <p className="mt-2 text-sm text-warning" role="status">
46 + Last successfully checked <LiveAgo at={s.last_success_at} tick={30000} />
47 + {s.last_failure_class ? ` — recent failures: ${s.last_failure_class} (${s.consecutive_failures} consecutive)` : ''}.
48 + </p>
49 + )}
50 + </div>
51 + <StatGrid cols={6}>
52 + <Stat label="Observations" value={fmtInt(s.observation_count)} size="sm" />
53 + <Stat label="Snapshots" value={fmtInt(s.snapshot_count)} size="sm" />
54 + <Stat label="Changes" value={fmtInt(s.change_count)} hint={`${fmtInt(s.meaningful_change_count)} meaningful`} size="sm" />
55 + <Stat label="Events" value={fmtInt(s.event_count)} size="sm" />
56 + <Stat label="Quality" value={fmtScore(s.quality_score)} size="sm" />
57 + <Stat label="Interval" value={fmtDuration(s.current_interval_s)} hint={`tier ${s.tier} · ${SENSOR_TIER_LABELS[s.tier] ?? ''}`} size="sm" />
58 + </StatGrid>
59 + <div className="mt-6 grid gap-8 lg:grid-cols-12">
60 + <div className="space-y-8 lg:col-span-8">
61 + <section>
62 + <p className="eyebrow mb-2">Snapshot versions</p>
63 + {!snaps ? (
64 + <Empty title="Snapshots unavailable." />
65 + ) : snaps.items.length === 0 ? (
66 + <Empty>No snapshot stored yet for this sensor.</Empty>
67 + ) : (
68 + <div className="table-scroll">
69 + <table className="data-table compact">
70 + <thead>
71 + <tr>
72 + <th className="num">v</th>
73 + <th>Fetched</th>
74 + <th>Title</th>
75 + <th className="num">Blocks</th>
76 + <th className="num">Text</th>
77 + <th>Hash</th>
78 + <th>Diff</th>
79 + </tr>
80 + </thead>
81 + <tbody>
82 + {snaps.items.map((v, i) => {
83 + const prev = snaps.items[i + 1];
84 + return (
85 + <tr key={v.id}>
86 + <td className="num tnum">{v.version_no}</td>
87 + <td className="tnum text-xs">
88 + <Link href={routes.snapshot(v.id)} className="link">
89 + {fmtDateTime(v.fetched_at)}
90 + </Link>
91 + </td>
92 + <td className="wrap text-ink-2">{v.title ?? '—'}</td>
93 + <td className="num tnum">{fmtInt(v.block_count)}</td>
94 + <td className="num tnum">{fmtInt(v.text_length)}</td>
95 + <td className="mono text-[11px] text-ink-3">{v.content_hash.replace('sha256:', '').slice(0, 10)}</td>
96 + <td className="text-xs">{prev ? <Link href={routes.snapshotDiff(prev.id, v.id)} className="link">vs v{prev.version_no}</Link> : <span className="text-ink-3">first</span>}</td>
97 + </tr>
98 + );
99 + })}
100 + </tbody>
101 + </table>
102 + </div>
103 + )}
104 + </section>
105 + <section>
106 + <p className="eyebrow mb-2">Detected changes</p>
107 + {!changes ? (
108 + <Empty title="Changes unavailable." />
109 + ) : changes.items.length === 0 ? (
110 + <Empty>No change detected yet.</Empty>
111 + ) : (
112 + <ul className="divide-y divide-rule border-y border-rule text-sm">
113 + {changes.items.map((c) => (
114 + <li key={c.id} className="flex flex-wrap items-center gap-2 py-2">
115 + <SignificanceBadge value={c.significance} />
116 + <Link href={routes.change(c.id)} className="link">
117 + {fmtDateTime(c.detected_at)}
118 + </Link>
119 + <span className="tnum text-xs text-ink-3">
120 + +{c.blocks_added} · −{c.blocks_removed} · ~{c.blocks_modified}
121 + </span>
122 + <span className="mono ml-auto text-[11px] text-ink-3">{c.kind}</span>
123 + </li>
124 + ))}
125 + </ul>
126 + )}
127 + </section>
128 + </div>
129 + <aside className="lg:col-span-4">
130 + <KV>
131 + <Row k="Connector">
132 + <span className="mono text-xs">{s.connector_id}</span>
133 + </Row>
134 + <Row k="Domain">
135 + <span className="mono text-xs">{s.domain}</span>
136 + </Row>
137 + <Row k="Discovery">
138 + {s.discovery_method ?? '—'} <span className="text-xs text-ink-3">· confidence {Math.round(s.discovery_confidence * 100)} %</span>
139 + </Row>
140 + <Row k="Last run">
141 + <LiveAgo at={s.last_run_at} tick={10000} />
142 + </Row>
143 + <Row k="Last success">
144 + <LiveAgo at={s.last_success_at} tick={10000} />
145 + </Row>
146 + <Row k="Last change">
147 + <LiveAgo at={s.last_change_at} tick={30000} />
148 + </Row>
149 + <Row k="Next run">{fmtDateTime(s.next_run_at)}</Row>
150 + <Row k="Last status">
151 + <span className="tnum">{s.last_status ?? '—'}</span>
152 + {s.last_failure_class && <span className="mono ml-2 text-xs text-danger">{s.last_failure_class}</span>}
153 + </Row>
154 + <Row k="Created">{fmtDateTime(s.created_at)}</Row>
155 + <Row k="Id">
156 + <span className="mono text-xs break-all">{s.id}</span>
157 + </Row>
158 + </KV>
159 + <Note className="mt-4">A sensor is a deployed connector instance attached to one public URL. It never bypasses authentication or challenges; when a page moves, the auto-repair loop tries redirects, sitemaps and navigation before flagging it for review.</Note>
160 + </aside>
161 + </div>
162 + </Container>
163 + );
164 +}
added apps/web/src/app/sitemap.xml/route.ts +11 −0
@@ -0,0 +1,11 @@
1 +import { generateSitemaps } from '../sitemaps/sitemap';
2 +import { SITE_URL } from '@/lib/site';
3 +
4 +/** Sitemap index: one entry per shard produced by `generateSitemaps` (served at /sitemaps/sitemap/<id>.xml). */
5 +export const revalidate = 3600;
6 +
7 +export async function GET(): Promise<Response> {
8 + const ids = await generateSitemaps();
9 + const body = `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${ids.map((s) => ` <sitemap><loc>${SITE_URL}/sitemaps/sitemap/${s.id}.xml</loc></sitemap>`).join('\n')}\n</sitemapindex>\n`;
10 + return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
11 +}
added apps/web/src/app/sitemaps/sitemap.ts +37 −0
@@ -0,0 +1,37 @@
1 +import type { MetadataRoute } from 'next';
2 +import { api, safe } from '@/lib/api';
3 +import { SITE_URL } from '@/lib/site';
4 +
5 +/**
6 + * Sharded sitemaps via `generateSitemaps` → served at /sitemaps/sitemap/<id>.xml; the index at /sitemap.xml is `app/sitemap.xml/route.ts`. Shards: `static`, `companies-<page>` (only `indexed = true` companies, per the API),
7 + * `industries`, `countries`. Built from `GET /sitemap?kind&page`.
8 + */
9 +export const revalidate = 3600;
10 +
11 +export async function generateSitemaps(): Promise<{ id: string }[]> {
12 + const ids: { id: string }[] = [{ id: 'static' }, { id: 'industries' }, { id: 'countries' }];
13 + const first = await safe(api.sitemap('companies', 0));
14 + const pages = Math.max(1, first?.pages ?? 1);
15 + for (let p = 0; p < pages; p++) ids.push({ id: `companies-${p}` });
16 + return ids;
17 +}
18 +
19 +const STATIC = ['/', '/live', '/companies', '/events', '/rankings', '/industry', '/country', '/company/compare', '/about', '/methodology', '/api', '/system', '/bot'];
20 +
21 +export default async function sitemap(props: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
22 + const id = await props.id;
23 + const now = new Date();
24 + if (id === 'static') return STATIC.map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: p === '/' || p === '/live' ? 'hourly' : 'daily', priority: p === '/' ? 1 : 0.7 }));
25 + if (id === 'industries') {
26 + const d = await safe(api.sitemap('industries', 0));
27 + return (d?.items ?? []).map((i) => ({ url: `${SITE_URL}/industry/${i.slug}`, lastModified: i.updated_at ? new Date(i.updated_at) : now, changeFrequency: 'daily', priority: 0.6 }));
28 + }
29 + if (id === 'countries') {
30 + const d = await safe(api.sitemap('countries', 0));
31 + return (d?.items ?? []).map((i) => ({ url: `${SITE_URL}/country/${i.slug.toLowerCase()}`, lastModified: i.updated_at ? new Date(i.updated_at) : now, changeFrequency: 'daily', priority: 0.6 }));
32 + }
33 + const m = /^companies-(\d+)$/.exec(id);
34 + if (!m) return [];
35 + const d = await safe(api.sitemap('companies', Number(m[1])));
36 + return (d?.items ?? []).map((i) => ({ url: `${SITE_URL}/company/${i.slug}`, lastModified: i.updated_at ? new Date(i.updated_at) : now, changeFrequency: 'daily', priority: 0.8 }));
37 +}
added apps/web/src/app/snapshot/[id]/diff/[other]/page.tsx +53 −0
@@ -0,0 +1,53 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { DiffViewer } from '@/components/events/diff-viewer';
5 +import { Container, Note } from '@/components/ui/section';
6 +import { api, ApiError } from '@/lib/api';
7 +import { fmtDateTime, fmtInt } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +
10 +export const revalidate = 3600;
11 +export const metadata: Metadata = { title: 'Snapshot diff', robots: { index: false } };
12 +
13 +export default async function SnapshotDiffPage({ params }: { params: Promise<{ id: string; other: string }> }) {
14 + const { id, other } = await params;
15 + let d;
16 + try {
17 + d = await api.snapshotDiff(id, other);
18 + } catch (e) {
19 + if (e instanceof ApiError && e.notFound) notFound();
20 + throw e;
21 + }
22 + return (
23 + <Container>
24 + <div className="pb-4 pt-6 md:pt-10">
25 + <p className="eyebrow">Historical page viewer · semantic diff</p>
26 + <h1 className="display mt-2 text-[24px] md:text-[32px]">
27 + v{d.before.version_no} → v{d.after.version_no}
28 + </h1>
29 + <div className="mt-3 grid gap-3 sm:grid-cols-2">
30 + {[d.before, d.after].map((s, i) => (
31 + <div key={s.id} className="border border-rule p-3 text-sm">
32 + <p className="eyebrow">{i === 0 ? 'Before' : 'After'}</p>
33 + <p className="mt-1 font-medium text-ink">{s.title ?? '—'}</p>
34 + <p className="tnum text-xs text-ink-3">
35 + {fmtDateTime(s.fetched_at)} · {fmtInt(s.block_count)} blocks · {fmtInt(s.text_length)} chars
36 + </p>
37 + <Link href={routes.snapshot(s.id)} className="link text-xs">
38 + Open version →
39 + </Link>
40 + </div>
41 + ))}
42 + </div>
43 + <p className="mt-2 text-xs text-ink-3">
44 + <Link href={routes.sensor(d.after.sensor_id)} className="link">
45 + Sensor
46 + </Link>
47 + </p>
48 + </div>
49 + <DiffViewer diff={d.diff} significance={null} />
50 + <Note className="mt-6">Computed on demand from the two stored snapshots. Block identities are stable across versions so moved content is not reported as removed + added.</Note>
51 + </Container>
52 + );
53 +}
added apps/web/src/app/snapshot/[id]/page.tsx +87 −0
@@ -0,0 +1,87 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { KV, Row } from '@/components/ui/key-value';
5 +import { Container, Note } from '@/components/ui/section';
6 +import { api, ApiError } from '@/lib/api';
7 +import { fmtDateTime, fmtInt } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +
10 +export const revalidate = 3600;
11 +export const metadata: Metadata = { title: 'Snapshot', robots: { index: false } };
12 +
13 +export default async function SnapshotPage({ params }: { params: Promise<{ id: string }> }) {
14 + const { id } = await params;
15 + let s;
16 + try {
17 + s = await api.snapshot(id);
18 + } catch (e) {
19 + if (e instanceof ApiError && e.notFound) notFound();
20 + throw e;
21 + }
22 + return (
23 + <Container>
24 + <div className="pb-4 pt-6 md:pt-10">
25 + <p className="eyebrow">Snapshot · version {s.version_no}</p>
26 + <h1 className="display mt-2 text-[24px] md:text-[32px]">{s.title ?? 'Normalised page version'}</h1>
27 + <p className="mt-2 text-sm text-ink-2">
28 + Fetched {fmtDateTime(s.fetched_at)} ·{' '}
29 + <Link href={routes.sensor(s.sensor_id)} className="link">
30 + sensor
31 + </Link>
32 + {s.previous_snapshot_id && (
33 + <>
34 + {' · '}
35 + <Link href={routes.snapshotDiff(s.previous_snapshot_id, s.id)} className="link">
36 + diff against previous version
37 + </Link>
38 + </>
39 + )}
40 + </p>
41 + </div>
42 + <div className="grid gap-8 lg:grid-cols-12">
43 + <article className="lg:col-span-8">
44 + <p className="eyebrow mb-2">Semantic blocks as observed</p>
45 + {s.blocks?.length ? (
46 + <ol className="space-y-2">
47 + {s.blocks.map((b) => (
48 + <li key={b.key} className="border-l-2 border-rule pl-3">
49 + <p className="mono text-[10px] uppercase tracking-wider text-ink-3">
50 + {b.kind} <span className="normal-case tracking-normal">· {b.path}</span>
51 + </p>
52 + <p className={`mt-0.5 text-sm ${b.kind === 'heading' ? 'font-semibold text-ink' : 'text-ink-2'}`}>{b.text}</p>
53 + </li>
54 + ))}
55 + </ol>
56 + ) : (
57 + <pre className="whitespace-pre-wrap border border-rule bg-surface-2 p-3 text-sm">{s.text}</pre>
58 + )}
59 + </article>
60 + <aside className="lg:col-span-4">
61 + <KV>
62 + <Row k="Language">{s.language ?? '—'}</Row>
63 + <Row k="Text length">
64 + <span className="tnum">{fmtInt(s.text_length)}</span>
65 + </Row>
66 + <Row k="Blocks">
67 + <span className="tnum">{fmtInt(s.block_count)}</span>
68 + </Row>
69 + <Row k="Content hash">
70 + <span className="mono text-xs break-all">{s.content_hash}</span>
71 + </Row>
72 + <Row k="Id">
73 + <span className="mono text-xs break-all">{s.id}</span>
74 + </Row>
75 + </KV>
76 + {Object.keys(s.extracted ?? {}).length > 0 && (
77 + <div className="mt-4">
78 + <p className="eyebrow mb-1">Extracted fields</p>
79 + <pre className="overflow-x-auto border border-rule bg-surface-2 p-3 text-xs">{JSON.stringify(s.extracted, null, 2)}</pre>
80 + </div>
81 + )}
82 + <Note className="mt-4">This is the normalised representation (scripts, styles, tracking parameters and rotating content removed). The raw object is kept in the content-addressed archive; identical pages share one object.</Note>
83 + </aside>
84 + </div>
85 + </Container>
86 + );
87 +}
added apps/web/src/app/system/page.tsx +71 −0
@@ -0,0 +1,71 @@
1 +import type { Metadata } from 'next';
2 +import { Columns } from '@/components/charts/bars';
3 +import { Dot, LiveAgo } from '@/components/ui/live';
4 +import { Container, Note, PageHeader, Stat, StatGrid, Unavailable } from '@/components/ui/section';
5 +import { api, safe } from '@/lib/api';
6 +import { fmtBytes, fmtDate, fmtDays, fmtInt, fmtPct } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'System status', description: 'Public aggregate health of the Company Atlas sensor network: sensors online, observations and events today, success rate and scheduler lag.' };
9 +export const revalidate = 30;
10 +
11 +export default async function SystemPage() {
12 + const [sys, stats, hist] = await Promise.all([safe(api.system()), safe(api.stats()), safe(api.statsHistory(30))]);
13 + const ok = sys && (sys.success_rate_24h ?? 100) >= 90 && (sys.queue_lag_s ?? 0) < 600;
14 + return (
15 + <Container>
16 + <PageHeader
17 + eyebrow={
18 + <>
19 + <Dot pulse={!!sys} tone={sys ? (ok ? 'live' : 'warning') : 'danger'} /> System
20 + </>
21 + }
22 + title={sys ? (ok ? 'All systems observing' : 'Degraded — observing with reduced throughput') : 'Status unavailable'}
23 + lede="Aggregate health only (spec §130): no per-sensor or per-domain detail is published here. Individual sensor freshness is shown on each company’s Sources tab."
24 + />
25 + {!sys ? (
26 + <Unavailable what="System health" />
27 + ) : (
28 + <StatGrid cols={4}>
29 + <Stat label="Sensors online" value={fmtInt(sys.sensors_online)} hint={`${fmtInt(sys.sensors_failing)} failing`} />
30 + <Stat label="Observations today" value={fmtInt(sys.observations_today)} hint={sys.fetch_per_min !== null ? `${fmtInt(sys.fetch_per_min)} fetches / min` : undefined} />
31 + <Stat label="Events today" value={fmtInt(sys.events_today)} hint={`${fmtInt(sys.countries_covered)} countries covered`} />
32 + <Stat label="Success rate 24 h" value={fmtPct(sys.success_rate_24h, 1)} hint={sys.queue_lag_s !== null ? `queue lag ${fmtInt(sys.queue_lag_s)} s` : undefined} />
33 + </StatGrid>
34 + )}
35 + {sys && (
36 + <p className="mt-3 text-xs text-ink-3">
37 + Scheduler last tick <LiveAgo at={sys.scheduler_last_tick_at} tick={5000} /> · this page refreshes every 30 s on the server.
38 + </p>
39 + )}
40 + {stats && (
41 + <div className="mt-10">
42 + <p className="eyebrow mb-3">Dataset</p>
43 + <StatGrid cols={4}>
44 + <Stat label="Dataset age" value={fmtDays(stats.dataset_age_days)} hint={stats.dataset_started_at ? `since ${fmtDate(stats.dataset_started_at)}` : undefined} size="sm" />
45 + <Stat label="Companies · sensors" value={`${fmtInt(stats.companies)} · ${fmtInt(stats.sensors)}`} size="sm" />
46 + <Stat label="Observations · changes · events" value={`${fmtInt(stats.observations)} · ${fmtInt(stats.changes)} · ${fmtInt(stats.events)}`} size="sm" />
47 + <Stat label="Archive" value={fmtBytes(stats.archive?.bytes)} hint={`${fmtInt(stats.archive?.objects)} objects`} size="sm" />
48 + </StatGrid>
49 + </div>
50 + )}
51 + {hist && hist.items.length > 1 && (
52 + <div className="mt-10 grid gap-6 md:grid-cols-3">
53 + {(
54 + [
55 + ['observations', 'Observations / day'],
56 + ['changes', 'Changes / day'],
57 + ['events', 'Events / day'],
58 + ] as const
59 + ).map(([k, label]) => (
60 + <div key={k}>
61 + <p className="eyebrow">{label} · 30 d</p>
62 + <Columns values={hist.items.map((h) => h[k])} width={300} height={48} className="mt-2 h-auto w-full" ariaLabel={label} />
63 + <p className="tnum mt-1 text-xs text-ink-3">latest {fmtInt(hist.items[hist.items.length - 1]?.[k])}</p>
64 + </div>
65 + ))}
66 + </div>
67 + )}
68 + <Note className="mt-10">Each service exposes /health, /ready and /metrics internally; this page summarises them. “Last successfully checked” timestamps on company pages are the honest freshness signal for any single fact.</Note>
69 + </Container>
70 + );
71 +}
added apps/web/src/app/watchlist/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import type { Metadata } from 'next';
2 +import { WatchlistClient } from '@/components/company/watchlist-client';
3 +import { Container, PageHeader } from '@/components/ui/section';
4 +
5 +export const metadata: Metadata = { title: 'Watchlist', robots: { index: false } };
6 +
7 +export default function WatchlistPage() {
8 + return (
9 + <Container wide>
10 + <PageHeader eyebrow="Watchlist" title="Companies you follow" lede="Watched companies, their latest structured events and your alert rules. Public browsing needs no account; the watchlist is tied to a token generated by this browser." />
11 + <WatchlistClient />
12 + </Container>
13 + );
14 +}
added apps/web/src/components/admin/admin-shell.tsx +105 −0
@@ -0,0 +1,105 @@
1 +'use client';
2 +import { KeyRound, LogOut, RefreshCw } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { type ReactNode, useState } from 'react';
6 +import { Container } from '@/components/ui/section';
7 +import { ADMIN_MODULES, setAdminToken, useAdminToken } from '@/lib/admin';
8 +import { cn } from '@/lib/cn';
9 +
10 +/** Token gate + module navigation. The token never leaves localStorage except as the `X-CA-Admin-Token` header. */
11 +export function AdminShell({ children, title, onRefresh, refreshing }: { children: ReactNode; title: string; onRefresh?: () => void; refreshing?: boolean }) {
12 + const token = useAdminToken();
13 + const pathname = usePathname();
14 + const [draft, setDraft] = useState('');
15 + if (token === undefined) return <Container className="py-10 text-sm text-ink-3">Loading…</Container>;
16 + if (!token)
17 + return (
18 + <Container className="py-12 md:py-20">
19 + <p className="eyebrow">Admin</p>
20 + <h1 className="display mt-2 text-3xl">Operator console</h1>
21 + <p className="mt-3 max-w-lg text-sm text-ink-2">Enter the admin token (`CA_ADMIN_TOKEN`). It is stored in this browser only and sent as <span className="mono">X-CA-Admin-Token</span> to same-origin API calls.</p>
22 + <form
23 + className="mt-5 flex max-w-md gap-2"
24 + onSubmit={(e) => {
25 + e.preventDefault();
26 + if (draft.trim()) setAdminToken(draft.trim());
27 + }}
28 + >
29 + <label htmlFor="admin-token" className="sr-only">
30 + Admin token
31 + </label>
32 + <input id="admin-token" type="password" value={draft} onChange={(e) => setDraft(e.target.value)} className="field flex-1" placeholder="admin token" autoComplete="off" data-admin-token />
33 + <button type="submit" className="btn btn-primary">
34 + <KeyRound className="size-4" aria-hidden /> Enter
35 + </button>
36 + </form>
37 + </Container>
38 + );
39 + return (
40 + <Container wide className="py-4 md:py-6">
41 + <div className="flex flex-col gap-4 lg:flex-row">
42 + <nav aria-label="Admin modules" className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 lg:mx-0 lg:w-48 lg:shrink-0 lg:flex-col lg:px-0">
43 + {ADMIN_MODULES.map((m) => {
44 + const href = `/admin/${m.id}`;
45 + const on = pathname === href || (m.id === 'overview' && pathname === '/admin');
46 + return (
47 + <Link key={m.id} href={href} className={cn('flex h-9 shrink-0 items-center rounded-sm px-2.5 text-[13px] whitespace-nowrap', on ? 'bg-surface-2 font-medium text-ink' : 'text-ink-2 hover:text-ink')} title={m.hint}>
48 + {m.label}
49 + </Link>
50 + );
51 + })}
52 + <button type="button" onClick={() => setAdminToken(null)} className="mt-auto flex h-9 shrink-0 items-center gap-1.5 rounded-sm px-2.5 text-[13px] text-ink-3 hover:text-danger">
53 + <LogOut className="size-3.5" aria-hidden /> Sign out
54 + </button>
55 + </nav>
56 + <div className="min-w-0 flex-1">
57 + <div className="mb-3 flex items-center justify-between gap-3">
58 + <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
59 + {onRefresh && (
60 + <button type="button" onClick={onRefresh} className="btn btn-sm" disabled={refreshing}>
61 + <RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} aria-hidden /> Refresh
62 + </button>
63 + )}
64 + </div>
65 + {children}
66 + </div>
67 + </div>
68 + </Container>
69 + );
70 +}
71 +
72 +export function AdminError({ error }: { error: string | null }) {
73 + if (!error) return null;
74 + return (
75 + <p className="mb-3 border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
76 + {error}
77 + {/401|403/.test(error) ? ' — the token was rejected. Sign out and enter it again.' : ''}
78 + </p>
79 + );
80 +}
81 +
82 +export function AdminTable({ head, children, className }: { head: ReactNode; children: ReactNode; className?: string }) {
83 + return (
84 + <div className={cn('table-scroll', className)}>
85 + <table className="data-table compact">
86 + <thead>{head}</thead>
87 + <tbody>{children}</tbody>
88 + </table>
89 + </div>
90 + );
91 +}
92 +
93 +export function KpiRow({ items }: { items: { label: string; value: ReactNode; hint?: ReactNode; tone?: 'positive' | 'warning' | 'danger' }[] }) {
94 + return (
95 + <div className="grid grid-cols-2 gap-x-6 border-y border-rule sm:grid-cols-3 lg:grid-cols-6 [&>*]:border-b [&>*]:border-rule lg:[&>*]:border-b-0">
96 + {items.map((i) => (
97 + <div key={i.label} className="min-w-0 py-3">
98 + <p className="eyebrow">{i.label}</p>
99 + <p className={cn('tnum mt-1 text-[22px] font-semibold leading-none tracking-tight', i.tone === 'positive' && 'text-positive', i.tone === 'warning' && 'text-warning', i.tone === 'danger' && 'text-danger')}>{i.value}</p>
100 + {i.hint && <p className="mt-1 text-[11px] text-ink-3">{i.hint}</p>}
101 + </div>
102 + ))}
103 + </div>
104 + );
105 +}
added apps/web/src/components/admin/modules.tsx +727 −0
@@ -0,0 +1,727 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { useCallback, useEffect, useState } from 'react';
4 +import { Bars } from '@/components/charts/bars';
5 +import { SensorTierBadge, StatusBadge } from '@/components/ui/badges';
6 +import { LiveAgo } from '@/components/ui/live';
7 +import { Note } from '@/components/ui/section';
8 +import { SkeletonRows } from '@/components/ui/skeleton';
9 +import { useAdminToken } from '@/lib/admin';
10 +import { adminApi } from '@/lib/client-api';
11 +import { fmt1, fmtBytes, fmtDateTime, fmtInt, fmtPct, fmtScore, fmtUsd, pathOf } from '@/lib/format';
12 +import { routes } from '@/lib/site';
13 +import type { AdminCompany, AdminConnector, AdminCosts, AdminFailure, AdminLlmJob, AdminOverview, AdminQuality, AdminQueueItem, AdminReview, AdminSensor, Page } from '@/lib/types';
14 +import { AdminError, AdminShell, AdminTable, KpiRow } from './admin-shell';
15 +
16 +/** Generic loader: fetches with the admin token, exposes refresh; children render data. */
17 +function useAdminData<T>(loader: (api: ReturnType<typeof adminApi>, signal: AbortSignal) => Promise<T>, deps: unknown[] = []) {
18 + const token = useAdminToken();
19 + const [data, setData] = useState<T | null>(null);
20 + const [error, setError] = useState<string | null>(null);
21 + const [loading, setLoading] = useState(false);
22 + const [tick, setTick] = useState(0);
23 + const refresh = useCallback(() => setTick((t) => t + 1), []);
24 + useEffect(() => {
25 + if (!token) return;
26 + const ctrl = new AbortController();
27 + setLoading(true);
28 + loader(adminApi(token), ctrl.signal)
29 + .then((d) => {
30 + setData(d);
31 + setError(null);
32 + })
33 + .catch((e: Error) => {
34 + if (e.name !== 'AbortError') setError(e.message);
35 + })
36 + .finally(() => setLoading(false));
37 + return () => ctrl.abort();
38 + // eslint-disable-next-line react-hooks/exhaustive-deps
39 + }, [token, tick, ...deps]);
40 + return { data, error, loading, refresh, token };
41 +}
42 +
43 +const entries = (o: Record<string, number> | undefined) => Object.entries(o ?? {}).sort((a, b) => b[1] - a[1]);
44 +
45 +/* -------------------------------------------------------------------------------------------------------------- overview */
46 +export function OverviewModule() {
47 + const { data, error, loading, refresh } = useAdminData<AdminOverview>((api, s) => api.overview(s));
48 + return (
49 + <AdminShell title="Overview" onRefresh={refresh} refreshing={loading}>
50 + <AdminError error={error} />
51 + {!data ? (
52 + <SkeletonRows />
53 + ) : (
54 + <div className="space-y-6">
55 + <KpiRow
56 + items={[
57 + { label: 'Queue pending', value: fmtInt(data.queue.pending), hint: `${fmtInt(data.queue.running)} running · oldest ${fmtInt(data.queue.oldest_pending_s)} s`, tone: data.queue.oldest_pending_s && data.queue.oldest_pending_s > 300 ? 'warning' : undefined },
58 + { label: 'Dead jobs', value: fmtInt(data.queue.dead), tone: data.queue.dead ? 'danger' : undefined },
59 + { label: 'Fetch / h', value: fmtInt(data.fetch_rate_1h) },
60 + { label: 'Changes / h', value: fmtInt(data.change_rate_1h), hint: `${fmtInt(data.meaningful_rate_1h)} meaningful` },
61 + { label: 'LLM pending', value: fmtInt(data.llm.pending), hint: `${fmtInt(data.llm.done_today)} done · ${fmtInt(data.llm.failed_today)} failed today` },
62 + { label: 'Cost today', value: fmtUsd(data.cost_today.fetch + data.cost_today.browser + data.cost_today.llm), hint: `fetch ${fmtUsd(data.cost_today.fetch)} · browser ${fmtUsd(data.cost_today.browser)} · llm ${fmtUsd(data.cost_today.llm)}` },
63 + ]}
64 + />
65 + <div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
66 + <div>
67 + <p className="eyebrow mb-2">Sensors by status</p>
68 + <Bars dense rows={entries(data.sensors_by_status).map(([k, v]) => ({ key: k, label: k, value: v, color: k === 'failing' ? 'var(--danger)' : k === 'active' ? 'var(--positive)' : undefined }))} />
69 + </div>
70 + <div>
71 + <p className="eyebrow mb-2">Sensors by tier</p>
72 + <Bars dense rows={entries(data.sensors_by_tier).map(([k, v]) => ({ key: k, label: `Tier ${k}`, value: v }))} />
73 + </div>
74 + <div>
75 + <p className="eyebrow mb-2">Failures 24 h by class</p>
76 + <Bars dense rows={entries(data.failures_24h_by_class).map(([k, v]) => ({ key: k, label: k, value: v, color: 'var(--danger)', href: `/admin/failures?class=${k}` }))} />
77 + </div>
78 + <div>
79 + <p className="eyebrow mb-2">Companies by status</p>
80 + <Bars dense rows={entries(data.companies_by_status).map(([k, v]) => ({ key: k, label: k, value: v }))} />
81 + </div>
82 + </div>
83 + <div className="grid gap-6 md:grid-cols-2">
84 + <div>
85 + <p className="eyebrow mb-2">Workers</p>
86 + <AdminTable
87 + head={
88 + <tr>
89 + <th>Name</th>
90 + <th>Last seen</th>
91 + <th className="num">In flight</th>
92 + </tr>
93 + }
94 + >
95 + {data.workers.map((w) => (
96 + <tr key={w.name}>
97 + <td className="mono primary">{w.name}</td>
98 + <td className="text-xs">
99 + <LiveAgo at={w.last_seen_at} tick={5000} />
100 + </td>
101 + <td className="num tnum">{w.inflight}</td>
102 + </tr>
103 + ))}
104 + </AdminTable>
105 + </div>
106 + <div>
107 + <p className="eyebrow mb-2">Storage</p>
108 + <p className="tnum text-sm">
109 + {fmtInt(data.storage.objects)} objects · {fmtBytes(data.storage.bytes)} · LLM budget left {data.llm.budget_left === null ? '—' : fmtPct(data.llm.budget_left, 0)}
110 + </p>
111 + </div>
112 + </div>
113 + </div>
114 + )}
115 + </AdminShell>
116 + );
117 +}
118 +
119 +/* -------------------------------------------------------------------------------------------------------------- connectors */
120 +export function ConnectorsModule() {
121 + const { data, error, loading, refresh } = useAdminData<{ items: AdminConnector[] }>((api, s) => api.connectors(s));
122 + return (
123 + <AdminShell title="Connector control center" onRefresh={refresh} refreshing={loading}>
124 + <AdminError error={error} />
125 + {!data ? (
126 + <SkeletonRows />
127 + ) : (
128 + <AdminTable
129 + head={
130 + <tr>
131 + <th>Connector</th>
132 + <th>Version</th>
133 + <th>Category</th>
134 + <th>Enabled</th>
135 + <th className="num">Active</th>
136 + <th className="num">Failing</th>
137 + <th className="num">Success 24 h</th>
138 + <th className="num">Latency</th>
139 + <th className="num">Change rate</th>
140 + <th className="num">Errors 24 h</th>
141 + <th>Last run</th>
142 + </tr>
143 + }
144 + >
145 + {data.items.map((c) => (
146 + <tr key={c.id}>
147 + <td className="primary">
148 + <Link href={`/admin/sensors?connector=${c.id}`} className="row-link">
149 + {c.name}
150 + </Link>
151 + <span className="mono block text-[11px] font-normal text-ink-3">{c.id}</span>
152 + </td>
153 + <td className="mono text-xs">{c.version}</td>
154 + <td className="text-ink-2">{c.category}</td>
155 + <td>{c.enabled ? <StatusBadge status="active" /> : <StatusBadge status="paused" />}</td>
156 + <td className="num tnum">{fmtInt(c.sensors_active)}</td>
157 + <td className={`num tnum ${c.sensors_failing ? 'text-danger' : ''}`}>{fmtInt(c.sensors_failing)}</td>
158 + <td className="num tnum">{fmtPct(c.success_rate_24h, 1)}</td>
159 + <td className="num tnum">{c.avg_latency_ms === null ? '—' : `${fmtInt(c.avg_latency_ms)} ms`}</td>
160 + <td className="num tnum">{fmtPct(c.change_rate_24h, 1)}</td>
161 + <td className="num tnum">{fmtInt(c.errors_24h)}</td>
162 + <td className="text-xs text-ink-3">
163 + <LiveAgo at={c.last_run_at} tick={10000} />
164 + </td>
165 + </tr>
166 + ))}
167 + </AdminTable>
168 + )}
169 + </AdminShell>
170 + );
171 +}
172 +
173 +/* -------------------------------------------------------------------------------------------------------------- sensors */
174 +const SENSOR_FILTERS = ['', 'healthy', 'failing', 'stale', 'blocked', 'redirected', 'low_quality', 'high_activity'];
175 +const ACTIONS = ['pause', 'resume', 'retry', 'rediscover', 'retire', 'run_now'];
176 +export function SensorsModule({ initial }: { initial: Record<string, string> }) {
177 + const [filter, setFilter] = useState(initial.filter ?? '');
178 + const [domain, setDomain] = useState(initial.domain ?? '');
179 + const [connector, setConnector] = useState(initial.connector ?? '');
180 + const [page, setPage] = useState(1);
181 + const [msg, setMsg] = useState<string | null>(null);
182 + const { data, error, loading, refresh, token } = useAdminData<Page<AdminSensor>>((api, s) => api.sensors({ filter: filter || undefined, domain: domain || undefined, connector: connector || undefined, page, per_page: 50 }, s), [filter, domain, connector, page]);
183 + const act = async (id: string, action: string) => {
184 + if (!token) return;
185 + const body: Record<string, unknown> = {};
186 + if (action === 'set_interval') {
187 + const v = prompt('New interval in seconds');
188 + if (!v) return;
189 + body.interval_s = Number(v);
190 + }
191 + try {
192 + await adminApi(token).sensorAction(id, action, body);
193 + setMsg(`${action} → ${id}`);
194 + refresh();
195 + } catch (e) {
196 + setMsg(`failed: ${(e as Error).message}`);
197 + }
198 + };
199 + return (
200 + <AdminShell title="Sensor control center" onRefresh={refresh} refreshing={loading}>
201 + <AdminError error={error} />
202 + <div className="mb-3 flex flex-wrap items-center gap-2">
203 + <div className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0">
204 + {SENSOR_FILTERS.map((f) => (
205 + <button key={f || 'all'} type="button" onClick={() => { setFilter(f); setPage(1); }} className="chip-btn" data-on={filter === f}>
206 + {f ? f.replace('_', ' ') : 'all'}
207 + </button>
208 + ))}
209 + </div>
210 + <input value={domain} onChange={(e) => { setDomain(e.target.value); setPage(1); }} placeholder="domain contains…" className="field h-9 w-44 text-xs" aria-label="Domain filter" />
211 + <input value={connector} onChange={(e) => { setConnector(e.target.value); setPage(1); }} placeholder="connector id" className="field h-9 w-40 text-xs" aria-label="Connector filter" />
212 + {msg && <span className="mono text-xs text-ink-3">{msg}</span>}
213 + </div>
214 + {!data ? (
215 + <SkeletonRows rows={10} />
216 + ) : (
217 + <>
218 + <AdminTable
219 + head={
220 + <tr>
221 + <th>Company</th>
222 + <th>Surface</th>
223 + <th>URL</th>
224 + <th>Status</th>
225 + <th>Tier</th>
226 + <th className="num">Q</th>
227 + <th className="num">Fails</th>
228 + <th>Last success</th>
229 + <th className="num">Interval</th>
230 + <th>Actions</th>
231 + </tr>
232 + }
233 + >
234 + {data.items.map((s) => (
235 + <tr key={s.id}>
236 + <td className="primary">
237 + <Link href={routes.company(s.company.slug)} className="row-link">
238 + {s.company.display_name}
239 + </Link>
240 + </td>
241 + <td>
242 + <Link href={routes.sensor(s.id)} className="row-link">
243 + {s.surface}
244 + </Link>
245 + </td>
246 + <td className="mono text-xs text-ink-2">{pathOf(s.url)}</td>
247 + <td>
248 + <StatusBadge status={s.status} />
249 + {s.last_failure_class && <span className="mono ml-1 text-[10px] text-danger">{s.last_failure_class}</span>}
250 + </td>
251 + <td>
252 + <SensorTierBadge tier={s.tier} />
253 + </td>
254 + <td className="num tnum">{fmtScore(s.quality_score)}</td>
255 + <td className={`num tnum ${s.consecutive_failures ? 'text-danger' : ''}`}>{s.consecutive_failures}</td>
256 + <td className="text-xs text-ink-3">
257 + <LiveAgo at={s.last_success_at} tick={30000} />
258 + </td>
259 + <td className="num tnum text-xs">{fmtInt(s.current_interval_s)} s</td>
260 + <td>
261 + <span className="flex flex-wrap gap-1">
262 + {ACTIONS.filter((a) => (s.status === 'paused' ? a !== 'pause' : a !== 'resume')).map((a) => (
263 + <button key={a} type="button" onClick={() => act(s.id, a)} className="border border-rule px-1.5 py-0.5 text-[11px] text-ink-2 hover:border-rule-strong hover:text-ink">
264 + {a.replace('_', ' ')}
265 + </button>
266 + ))}
267 + <button type="button" onClick={() => act(s.id, 'set_interval')} className="border border-rule px-1.5 py-0.5 text-[11px] text-ink-2 hover:text-ink">
268 + interval
269 + </button>
270 + </span>
271 + </td>
272 + </tr>
273 + ))}
274 + </AdminTable>
275 + <div className="mt-2 flex items-center justify-between text-xs text-ink-3">
276 + <span className="tnum">
277 + page {data.page} / {data.pages} · {fmtInt(data.total)} sensors
278 + </span>
279 + <span className="flex gap-1">
280 + <button type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} className="btn btn-sm">
281 + ‹ Prev
282 + </button>
283 + <button type="button" onClick={() => setPage((p) => Math.min(data.pages, p + 1))} disabled={page >= data.pages} className="btn btn-sm">
284 + Next ›
285 + </button>
286 + </span>
287 + </div>
288 + </>
289 + )}
290 + </AdminShell>
291 + );
292 +}
293 +
294 +/* -------------------------------------------------------------------------------------------------------------- companies */
295 +export function CompaniesModule() {
296 + const [status, setStatus] = useState('');
297 + const [page, setPage] = useState(1);
298 + const [form, setForm] = useState({ website: '', display_name: '', country: '', industries: '' });
299 + const [msg, setMsg] = useState<string | null>(null);
300 + const { data, error, loading, refresh, token } = useAdminData<Page<AdminCompany>>((api, s) => api.companies({ onboarding_status: status || undefined, page, per_page: 50 }, s), [status, page]);
301 + const submit = async (e: React.FormEvent) => {
302 + e.preventDefault();
303 + if (!token || !form.website) return;
304 + try {
305 + await adminApi(token).createCompany({ website: form.website, display_name: form.display_name || undefined, country: form.country || undefined, industries: form.industries ? form.industries.split(',').map((s) => s.trim()).filter(Boolean) : undefined });
306 + setMsg(`queued discovery for ${form.website}`);
307 + setForm({ website: '', display_name: '', country: '', industries: '' });
308 + refresh();
309 + } catch (err) {
310 + setMsg(`failed: ${(err as Error).message}`);
311 + }
312 + };
313 + return (
314 + <AdminShell title="Companies" onRefresh={refresh} refreshing={loading}>
315 + <AdminError error={error} />
316 + <form onSubmit={submit} className="mb-4 grid gap-2 border border-rule p-3 sm:grid-cols-[2fr_1.5fr_0.7fr_1.5fr_auto]">
317 + <input value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })} placeholder="https://example.com (required)" className="field text-sm" aria-label="Website" required />
318 + <input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} placeholder="Display name" className="field text-sm" aria-label="Display name" />
319 + <input value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value.toUpperCase() })} placeholder="CC" maxLength={2} className="field text-sm" aria-label="Country code" />
320 + <input value={form.industries} onChange={(e) => setForm({ ...form, industries: e.target.value })} placeholder="industries, comma-separated" className="field text-sm" aria-label="Industries" />
321 + <button type="submit" className="btn btn-primary">
322 + Add company
323 + </button>
324 + </form>
325 + {msg && <p className="mono mb-2 text-xs text-ink-3">{msg}</p>}
326 + <div className="mb-2 flex gap-1">
327 + {['', 'pending', 'discovering', 'active', 'failed'].map((s) => (
328 + <button key={s || 'all'} type="button" onClick={() => { setStatus(s); setPage(1); }} className="chip-btn" data-on={status === s}>
329 + {s || 'all'}
330 + </button>
331 + ))}
332 + </div>
333 + {!data ? (
334 + <SkeletonRows rows={10} />
335 + ) : (
336 + <AdminTable
337 + head={
338 + <tr>
339 + <th>Company</th>
340 + <th>Domain</th>
341 + <th>Country</th>
342 + <th>Status</th>
343 + <th>Onboarding</th>
344 + <th className="num">Tier</th>
345 + <th className="num">Sensors</th>
346 + <th className="num">Events</th>
347 + <th>Last observed</th>
348 + <th />
349 + </tr>
350 + }
351 + >
352 + {data.items.map((c) => (
353 + <tr key={c.id}>
354 + <td className="primary">
355 + <Link href={routes.company(c.slug)} className="row-link">
356 + {c.display_name}
357 + </Link>
358 + </td>
359 + <td className="mono text-xs">{c.canonical_domain}</td>
360 + <td className="mono text-xs">{c.country ?? '—'}</td>
361 + <td>
362 + <StatusBadge status={c.status} />
363 + </td>
364 + <td>
365 + <StatusBadge status={c.onboarding_status} />
366 + </td>
367 + <td className="num tnum">{c.tier}</td>
368 + <td className="num tnum">{fmtInt(c.counts.sensors)}</td>
369 + <td className="num tnum">{fmtInt(c.counts.events)}</td>
370 + <td className="text-xs text-ink-3">
371 + <LiveAgo at={c.last_observed_at} tick={30000} />
372 + </td>
373 + <td>
374 + <button type="button" onClick={() => token && adminApi(token).rediscover(c.slug).then(() => setMsg(`rediscover queued for ${c.slug}`)).catch((e: Error) => setMsg(e.message))} className="border border-rule px-1.5 py-0.5 text-[11px] text-ink-2 hover:text-ink">
375 + rediscover
376 + </button>
377 + </td>
378 + </tr>
379 + ))}
380 + </AdminTable>
381 + )}
382 + </AdminShell>
383 + );
384 +}
385 +
386 +/* -------------------------------------------------------------------------------------------------------------- failures */
387 +export function FailuresModule({ initialClass }: { initialClass?: string }) {
388 + const [cls, setCls] = useState(initialClass ?? '');
389 + const { data, error, loading, refresh } = useAdminData<Page<AdminFailure>>((api, s) => api.failures({ class: cls || undefined, per_page: 100 }, s), [cls]);
390 + return (
391 + <AdminShell title="Failures" onRefresh={refresh} refreshing={loading}>
392 + <AdminError error={error} />
393 + <div className="mb-2 flex flex-wrap items-center gap-2">
394 + <input value={cls} onChange={(e) => setCls(e.target.value.toUpperCase())} placeholder="class (TIMEOUT, HTTP_4XX, BOT_CHALLENGE…)" className="field h-9 w-72 text-xs" aria-label="Failure class" />
395 + </div>
396 + {!data ? (
397 + <SkeletonRows rows={10} />
398 + ) : (
399 + <AdminTable
400 + head={
401 + <tr>
402 + <th>When</th>
403 + <th>Class</th>
404 + <th className="num">HTTP</th>
405 + <th>Company</th>
406 + <th>Domain</th>
407 + <th>Message</th>
408 + <th>Retry</th>
409 + </tr>
410 + }
411 + >
412 + {data.items.map((f) => (
413 + <tr key={f.id}>
414 + <td className="tnum text-xs">{fmtDateTime(f.occurred_at)}</td>
415 + <td className="mono text-xs text-danger">{f.failure_class}</td>
416 + <td className="num tnum">{f.status_code || '—'}</td>
417 + <td>{f.company ? <Link href={routes.company(f.company.slug)} className="row-link">{f.company.display_name}</Link> : '—'}</td>
418 + <td className="mono text-xs">{f.domain ?? '—'}</td>
419 + <td className="wrap text-xs text-ink-2">{f.message ?? '—'}</td>
420 + <td className="text-xs text-ink-3">{f.retry_at ? <LiveAgo at={f.retry_at} tick={30000} /> : '—'}</td>
421 + </tr>
422 + ))}
423 + </AdminTable>
424 + )}
425 + </AdminShell>
426 + );
427 +}
428 +
429 +/* -------------------------------------------------------------------------------------------------------------- queue */
430 +export function QueueModule() {
431 + const [kind, setKind] = useState('');
432 + const [status, setStatus] = useState('');
433 + const [msg, setMsg] = useState<string | null>(null);
434 + const { data, error, loading, refresh, token } = useAdminData<{ items: AdminQueueItem[]; counts?: Record<string, number> } | Page<AdminQueueItem>>((api, s) => api.queue({ kind: kind || undefined, status: status || undefined }, s), [kind, status]);
435 + const items = data?.items ?? [];
436 + const counts = data && 'counts' in data ? data.counts : undefined;
437 + return (
438 + <AdminShell title="Crawl queue" onRefresh={refresh} refreshing={loading}>
439 + <AdminError error={error} />
440 + <div className="mb-3 flex flex-wrap items-center gap-2">
441 + <select value={kind} onChange={(e) => setKind(e.target.value)} className="field h-9 text-xs" aria-label="Kind">
442 + <option value="">all kinds</option>
443 + {['fetch', 'discover', 'enrich', 'metrics', 'daily'].map((k) => (
444 + <option key={k}>{k}</option>
445 + ))}
446 + </select>
447 + <select value={status} onChange={(e) => setStatus(e.target.value)} className="field h-9 text-xs" aria-label="Status">
448 + <option value="">all statuses</option>
449 + {['pending', 'running', 'done', 'dead'].map((k) => (
450 + <option key={k}>{k}</option>
451 + ))}
452 + </select>
453 + {counts && <span className="mono text-xs text-ink-3">{Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(' · ')}</span>}
454 + <button type="button" onClick={() => token && adminApi(token).requeueDead().then((r) => { setMsg(JSON.stringify(r)); refresh(); }).catch((e: Error) => setMsg(e.message))} className="btn btn-sm ml-auto">
455 + Requeue dead
456 + </button>
457 + {msg && <span className="mono text-xs text-ink-3">{msg}</span>}
458 + </div>
459 + {!data ? (
460 + <SkeletonRows rows={10} />
461 + ) : (
462 + <AdminTable
463 + head={
464 + <tr>
465 + <th>Id</th>
466 + <th>Kind</th>
467 + <th>Status</th>
468 + <th className="num">Prio</th>
469 + <th className="num">Attempts</th>
470 + <th>Scheduled</th>
471 + <th>Worker</th>
472 + <th>Ref</th>
473 + <th>Error</th>
474 + </tr>
475 + }
476 + >
477 + {items.map((j) => (
478 + <tr key={j.id}>
479 + <td className="mono text-[11px] text-ink-3">{j.id}</td>
480 + <td>{j.kind}</td>
481 + <td>
482 + <StatusBadge status={j.status} />
483 + </td>
484 + <td className="num tnum">{j.priority}</td>
485 + <td className="num tnum">{j.attempts}</td>
486 + <td className="text-xs text-ink-3">
487 + <LiveAgo at={j.scheduled_at} tick={10000} />
488 + </td>
489 + <td className="mono text-xs">{j.worker ?? '—'}</td>
490 + <td className="mono text-[11px] text-ink-3">{j.ref ?? '—'}</td>
491 + <td className="text-xs text-danger">{j.error ?? ''}</td>
492 + </tr>
493 + ))}
494 + </AdminTable>
495 + )}
496 + </AdminShell>
497 + );
498 +}
499 +
500 +/* -------------------------------------------------------------------------------------------------------------- llm */
501 +export function LlmModule() {
502 + const [status, setStatus] = useState('');
503 + const { data, error, loading, refresh } = useAdminData<Page<AdminLlmJob>>((api, s) => api.llm({ status: status || undefined, per_page: 100 }, s), [status]);
504 + return (
505 + <AdminShell title="LLM jobs" onRefresh={refresh} refreshing={loading}>
506 + <AdminError error={error} />
507 + <div className="mb-2 flex gap-1">
508 + {['', 'pending', 'done', 'failed'].map((s) => (
509 + <button key={s || 'all'} type="button" onClick={() => setStatus(s)} className="chip-btn" data-on={status === s}>
510 + {s || 'all'}
511 + </button>
512 + ))}
513 + </div>
514 + {!data ? (
515 + <SkeletonRows rows={10} />
516 + ) : (
517 + <AdminTable
518 + head={
519 + <tr>
520 + <th>Created</th>
521 + <th>Kind</th>
522 + <th>Status</th>
523 + <th>Model</th>
524 + <th>Prompt</th>
525 + <th className="num">Tokens in / out</th>
526 + <th className="num">Cost</th>
527 + <th>Change</th>
528 + <th>Event</th>
529 + <th>Error</th>
530 + </tr>
531 + }
532 + >
533 + {data.items.map((j) => (
534 + <tr key={j.id}>
535 + <td className="tnum text-xs">{fmtDateTime(j.created_at)}</td>
536 + <td>{j.kind}</td>
537 + <td>
538 + <StatusBadge status={j.status} />
539 + </td>
540 + <td className="mono text-xs">{j.model ?? '—'}</td>
541 + <td className="mono text-xs text-ink-3">{j.prompt_version ?? '—'}</td>
542 + <td className="num tnum text-xs">
543 + {fmtInt(j.tokens_in)} / {fmtInt(j.tokens_out)}
544 + </td>
545 + <td className="num tnum text-xs">{j.cost_estimate === null ? '—' : `$${j.cost_estimate.toFixed(3)}`}</td>
546 + <td>{j.change_id ? <Link href={routes.change(j.change_id)} className="link mono text-[11px]">{j.change_id.slice(0, 12)}…</Link> : '—'}</td>
547 + <td>{j.event_id ? <Link href={routes.event(j.event_id)} className="link mono text-[11px]">{j.event_id.slice(0, 12)}…</Link> : '—'}</td>
548 + <td className="text-xs text-danger">{j.error ?? ''}</td>
549 + </tr>
550 + ))}
551 + </AdminTable>
552 + )}
553 + </AdminShell>
554 + );
555 +}
556 +
557 +/* -------------------------------------------------------------------------------------------------------------- reviews */
558 +export function ReviewsModule() {
559 + const [status, setStatus] = useState('open');
560 + const [msg, setMsg] = useState<string | null>(null);
561 + const { data, error, loading, refresh, token } = useAdminData<{ items: AdminReview[] } | Page<AdminReview>>((api, s) => api.reviews({ status: status || undefined }, s), [status]);
562 + const resolve = async (id: string, resolution: 'accepted' | 'rejected') => {
563 + if (!token) return;
564 + try {
565 + await adminApi(token).resolveReview(id, resolution);
566 + setMsg(`${resolution}: ${id}`);
567 + refresh();
568 + } catch (e) {
569 + setMsg((e as Error).message);
570 + }
571 + };
572 + const retract = async (eventId: string) => {
573 + if (!token) return;
574 + const reason = prompt('Retraction reason (kept in the audit history)');
575 + if (!reason) return;
576 + try {
577 + await adminApi(token).retractEvent(eventId, reason);
578 + setMsg(`retracted ${eventId}`);
579 + } catch (e) {
580 + setMsg((e as Error).message);
581 + }
582 + };
583 + return (
584 + <AdminShell title="Review queue" onRefresh={refresh} refreshing={loading}>
585 + <AdminError error={error} />
586 + <div className="mb-2 flex items-center gap-1">
587 + {['open', 'resolved', ''].map((s) => (
588 + <button key={s || 'all'} type="button" onClick={() => setStatus(s)} className="chip-btn" data-on={status === s}>
589 + {s || 'all'}
590 + </button>
591 + ))}
592 + {msg && <span className="mono ml-2 text-xs text-ink-3">{msg}</span>}
593 + </div>
594 + {!data ? (
595 + <SkeletonRows rows={8} />
596 + ) : (
597 + <ul className="divide-y divide-rule border-y border-rule">
598 + {data.items.map((r) => (
599 + <li key={r.id} className="py-3">
600 + <div className="flex flex-wrap items-center gap-2 text-sm">
601 + <span className="mono text-[11px] uppercase tracking-wider text-ink-3">{r.kind.replace(/_/g, ' ')}</span>
602 + <StatusBadge status={r.status} />
603 + {r.company && (
604 + <Link href={routes.company(r.company.slug)} className="font-medium text-ink hover:text-accent">
605 + {r.company.display_name}
606 + </Link>
607 + )}
608 + <span className="ml-auto text-xs text-ink-3">{fmtDateTime(r.created_at)}</span>
609 + </div>
610 + <p className="mt-1 text-[14px] text-ink">{r.subject}</p>
611 + {r.reason && <p className="text-xs text-ink-3">reason: {r.reason}</p>}
612 + {r.status === 'open' && (
613 + <div className="mt-2 flex flex-wrap gap-1.5">
614 + <button type="button" onClick={() => resolve(r.id, 'accepted')} className="btn btn-sm">
615 + Accept
616 + </button>
617 + <button type="button" onClick={() => resolve(r.id, 'rejected')} className="btn btn-sm">
618 + Reject
619 + </button>
620 + {r.ref_id?.startsWith('evt_') && (
621 + <>
622 + <Link href={routes.event(r.ref_id)} className="btn btn-sm">
623 + Open event
624 + </Link>
625 + <button type="button" onClick={() => retract(r.ref_id as string)} className="btn btn-sm text-danger">
626 + Retract event
627 + </button>
628 + </>
629 + )}
630 + </div>
631 + )}
632 + </li>
633 + ))}
634 + </ul>
635 + )}
636 + </AdminShell>
637 + );
638 +}
639 +
640 +/* -------------------------------------------------------------------------------------------------------------- quality */
641 +export function QualityModule() {
642 + const { data, error, loading, refresh } = useAdminData<AdminQuality>((api, s) => api.quality(s));
643 + return (
644 + <AdminShell title="Data quality" onRefresh={refresh} refreshing={loading}>
645 + <AdminError error={error} />
646 + {!data ? (
647 + <SkeletonRows />
648 + ) : (
649 + <div className="space-y-6">
650 + <KpiRow
651 + items={[
652 + { label: 'Companies active', value: fmtPct(data.coverage.companies_active_pct, 1) },
653 + { label: 'Sensors active', value: fmtPct(data.coverage.sensors_active_pct, 1) },
654 + { label: 'Checked in 24 h', value: fmtPct(data.freshness.sensors_checked_24h_pct, 1), hint: `${fmtInt(data.freshness.stale)} stale` },
655 + { label: 'Duplicate rate', value: fmtPct(data.duplicate_rate, 2), tone: (data.duplicate_rate ?? 0) > 3 ? 'warning' : undefined },
656 + { label: 'Event confidence', value: data.event_confidence_avg === null ? '—' : fmt1(data.event_confidence_avg * 100) + ' %' },
657 + { label: 'Failed sensors', value: fmtInt(data.failed_sensors), tone: data.failed_sensors ? 'danger' : undefined, hint: `${fmtInt(data.unknown_surfaces)} unknown surfaces` },
658 + ]}
659 + />
660 + <div className="max-w-md">
661 + <p className="eyebrow mb-2">Calibration (human-labelled sample)</p>
662 + <Bars rows={Object.entries(data.calibration).map(([k, v]) => ({ key: k, label: k, value: v, color: k === 'correct' ? 'var(--positive)' : k === 'misclassified' ? 'var(--danger)' : 'var(--warning)' }))} />
663 + </div>
664 + <Note>Calibration counts feed the evaluation set used before changing extractors, normalisers or prompts.</Note>
665 + </div>
666 + )}
667 + </AdminShell>
668 + );
669 +}
670 +
671 +/* -------------------------------------------------------------------------------------------------------------- costs */
672 +export function CostsModule() {
673 + const [days, setDays] = useState(30);
674 + const { data, error, loading, refresh } = useAdminData<AdminCosts>((api, s) => api.costs(days, s), [days]);
675 + const byDim: Record<string, { units: number; cost: number }> = {};
676 + for (const i of data?.items ?? []) {
677 + const k = `${i.dimension} · ${i.key}`;
678 + byDim[k] ??= { units: 0, cost: 0 };
679 + byDim[k].units += i.units;
680 + byDim[k].cost += i.cost_estimate;
681 + }
682 + return (
683 + <AdminShell title="Cost accounting" onRefresh={refresh} refreshing={loading}>
684 + <AdminError error={error} />
685 + <div className="mb-3 flex gap-1">
686 + {[7, 30, 90].map((d) => (
687 + <button key={d} type="button" onClick={() => setDays(d)} className="chip-btn" data-on={days === d}>
688 + {d} d
689 + </button>
690 + ))}
691 + </div>
692 + {!data ? (
693 + <SkeletonRows />
694 + ) : (
695 + <div className="space-y-6">
696 + <KpiRow
697 + items={[
698 + { label: 'Per 1 000 companies', value: fmtUsd(data.per_1000_companies) },
699 + { label: 'Per 1M observations', value: fmtUsd(data.per_million_observations) },
700 + { label: 'Per meaningful event', value: data.per_meaningful_event === null ? '—' : `$${data.per_meaningful_event.toFixed(4)}` },
701 + { label: `Total ${days} d`, value: fmtUsd(Object.values(byDim).reduce((a, b) => a + b.cost, 0)) },
702 + ]}
703 + />
704 + <AdminTable
705 + head={
706 + <tr>
707 + <th>Dimension</th>
708 + <th className="num">Units</th>
709 + <th className="num">Cost estimate</th>
710 + </tr>
711 + }
712 + >
713 + {Object.entries(byDim)
714 + .sort((a, b) => b[1].cost - a[1].cost)
715 + .map(([k, v]) => (
716 + <tr key={k}>
717 + <td className="primary">{k}</td>
718 + <td className="num tnum">{fmtInt(v.units)}</td>
719 + <td className="num tnum">{fmtUsd(v.cost)}</td>
720 + </tr>
721 + ))}
722 + </AdminTable>
723 + </div>
724 + )}
725 + </AdminShell>
726 + );
727 +}
added apps/web/src/components/brand/logo.tsx +25 −0
@@ -0,0 +1,25 @@
1 +import { cn } from '@/lib/cn';
2 +import { MARK_TOKENS, MarkArt } from './mark';
3 +
4 +/** The mark; `plate` follows the theme through `--brand-*` tokens, `mono` draws the globe in currentColor. */
5 +export function LogoMark({ size = 24, className, variant = 'plate', title }: { size?: number; className?: string; variant?: 'plate' | 'mono'; title?: string }) {
6 + const colors = variant === 'plate' ? MARK_TOKENS : { plate: 'transparent', ink: 'currentColor', grid: 'currentColor', accent: 'var(--live)' };
7 + return (
8 + <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn('shrink-0', className)} aria-hidden={title ? undefined : true} role={title ? 'img' : undefined}>
9 + {title && <title>{title}</title>}
10 + <MarkArt colors={colors} plate={variant === 'plate'} simplified={size <= 20} />
11 + </svg>
12 + );
13 +}
14 +
15 +/** Lockup: mark + "Company Atlas" ("Company" medium, "Atlas" bold). */
16 +export function Wordmark({ className, markSize = 22, textClassName }: { className?: string; markSize?: number; textClassName?: string }) {
17 + return (
18 + <span className={cn('inline-flex items-center gap-2 text-ink', className)}>
19 + <LogoMark size={markSize} />
20 + <span className={cn('text-[17px] tracking-tight', textClassName)}>
21 + <span className="font-medium text-ink-2">Company</span> <span className="font-bold">Atlas</span>
22 + </span>
23 + </span>
24 + );
25 +}
added apps/web/src/components/brand/mark.tsx +55 −0
@@ -0,0 +1,55 @@
1 +/**
2 + * The Company Atlas mark — an atlas plate: a rounded square carrying a globe grid (a meridian ellipse + equator + two
3 + * parallels) with a bright "now" node on the equator's right edge, where the live observation sits on the timeline.
4 + * One 32 × 32 geometry for the favicon, the header lockup, the Apple icon and the OG images.
5 + */
6 +export type MarkColors = { plate: string; ink: string; grid: string; accent: string };
7 +
8 +export const MARK_DARK: MarkColors = { plate: '#0a0d12', ink: '#e8ebf1', grid: 'rgba(232,235,241,0.34)', accent: '#3fd07a' };
9 +export const MARK_LIGHT: MarkColors = { plate: '#e8ebf1', ink: '#0a0d12', grid: 'rgba(10,13,18,0.3)', accent: '#16a34a' };
10 +export const MARK_TOKENS: MarkColors = { plate: 'var(--brand-plate)', ink: 'var(--brand-ink)', grid: 'var(--brand-grid)', accent: 'var(--brand-accent)' };
11 +
12 +type MarkOpts = { simplified?: boolean; plate?: boolean; radius?: number };
13 +
14 +export function MarkArt({ colors, simplified = false, plate = true, radius = 7 }: { colors: MarkColors } & MarkOpts) {
15 + const sw = simplified ? 1.6 : 1.3;
16 + return (
17 + <>
18 + {plate && <rect x="1" y="1" width="30" height="30" rx={radius} fill={colors.plate} />}
19 + {/* globe outline */}
20 + <circle cx="16" cy="16" r="10" stroke={colors.ink} strokeWidth={sw} fill="none" />
21 + {/* central meridian ellipse */}
22 + <ellipse cx="16" cy="16" rx="4.2" ry="10" stroke={colors.grid} strokeWidth={simplified ? 1.1 : 0.9} fill="none" />
23 + {/* parallels */}
24 + {!simplified && <line x1="7.6" x2="24.4" y1="11" y2="11" stroke={colors.grid} strokeWidth="0.9" />}
25 + {!simplified && <line x1="7.6" x2="24.4" y1="21" y2="21" stroke={colors.grid} strokeWidth="0.9" />}
26 + {/* equator (timeline) */}
27 + <line x1="6" x2="26" y1="16" y2="16" stroke={colors.ink} strokeWidth={sw} strokeLinecap="round" />
28 + {/* the "now" node */}
29 + <circle cx="26" cy="16" r={simplified ? 2.6 : 2.3} fill={colors.accent} />
30 + </>
31 + );
32 +}
33 +
34 +export function markSvgString(colors: MarkColors, { simplified = false, plate = true, radius = 7 }: MarkOpts = {}): string {
35 + const sw = simplified ? 1.6 : 1.3;
36 + const parts = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">`];
37 + if (plate) parts.push(`<rect x="1" y="1" width="30" height="30" rx="${radius}" fill="${colors.plate}"/>`);
38 + parts.push(`<circle cx="16" cy="16" r="10" stroke="${colors.ink}" stroke-width="${sw}" fill="none"/>`);
39 + parts.push(`<ellipse cx="16" cy="16" rx="4.2" ry="10" stroke="${colors.grid}" stroke-width="${simplified ? 1.1 : 0.9}" fill="none"/>`);
40 + if (!simplified) parts.push(`<line x1="7.6" x2="24.4" y1="11" y2="11" stroke="${colors.grid}" stroke-width="0.9"/>`, `<line x1="7.6" x2="24.4" y1="21" y2="21" stroke="${colors.grid}" stroke-width="0.9"/>`);
41 + parts.push(`<line x1="6" x2="26" y1="16" y2="16" stroke="${colors.ink}" stroke-width="${sw}" stroke-linecap="round"/>`);
42 + parts.push(`<circle cx="26" cy="16" r="${simplified ? 2.6 : 2.3}" fill="${colors.accent}"/>`);
43 + parts.push('</svg>');
44 + return parts.join('');
45 +}
46 +
47 +export function markDataUri(colors: MarkColors, opts?: MarkOpts): string {
48 + return `data:image/svg+xml;utf8,${encodeURIComponent(markSvgString(colors, opts))}`;
49 +}
50 +
51 +/** For ImageResponse routes (satori rasterises <img> data URIs reliably). Fixed hex colours only. */
52 +export function MarkImg({ px, colors = MARK_DARK, simplified = false, plate = true, radius }: { px: number; colors?: MarkColors } & MarkOpts) {
53 + // eslint-disable-next-line @next/next/no-img-element
54 + return <img width={px} height={px} src={markDataUri(colors, { simplified, plate, radius })} alt="" style={{ width: px, height: px }} />;
55 +}
added apps/web/src/components/charts/bars.tsx +57 −0
@@ -0,0 +1,57 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { fmtInt } from '@/lib/format';
5 +
6 +export type BarRow = { key: string; label: ReactNode; value: number; href?: string; color?: string; hint?: ReactNode };
7 +
8 +/** Horizontal bars (server-renderable): label · bar · value. Sorted by the caller. */
9 +export function Bars({ rows, className, format = fmtInt, max: maxOverride, dense = false }: { rows: BarRow[]; className?: string; format?: (v: number) => string; max?: number; dense?: boolean }) {
10 + const max = maxOverride ?? Math.max(1, ...rows.map((r) => Math.abs(r.value)));
11 + if (!rows.length) return null;
12 + return (
13 + <ul className={cn('space-y-1', className)}>
14 + {rows.map((r) => {
15 + const pct = Math.min(100, (Math.abs(r.value) / max) * 100);
16 + const inner = (
17 + <>
18 + <span className={cn('min-w-0 truncate', dense ? 'text-xs' : 'text-[13px]')}>{r.label}</span>
19 + <span className="meter">
20 + <span style={{ width: `${pct}%`, background: r.color ?? (r.value < 0 ? 'var(--danger)' : 'var(--accent)') }} />
21 + </span>
22 + <span className={cn('tnum text-right text-ink', dense ? 'text-xs' : 'text-[13px]')}>{format(r.value)}</span>
23 + {r.hint !== undefined && <span className="col-span-3 text-[11px] text-ink-3">{r.hint}</span>}
24 + </>
25 + );
26 + const cls = cn('grid grid-cols-[minmax(0,1fr)_minmax(4rem,2fr)_auto] items-center gap-x-3', dense ? 'py-0.5' : 'py-1');
27 + return (
28 + <li key={r.key}>
29 + {r.href ? (
30 + <Link href={r.href} className={cn(cls, 'text-ink-2 hover:text-accent')}>
31 + {inner}
32 + </Link>
33 + ) : (
34 + <div className={cn(cls, 'text-ink-2')}>{inner}</div>
35 + )}
36 + </li>
37 + );
38 + })}
39 + </ul>
40 + );
41 +}
42 +
43 +/** Tiny vertical column strip (e.g. events per day) — SVG, server-renderable. */
44 +export function Columns({ values, width = 120, height = 28, className, color = 'var(--accent)', ariaLabel }: { values: number[]; width?: number; height?: number; className?: string; color?: string; ariaLabel?: string }) {
45 + const max = Math.max(1, ...values);
46 + const n = values.length || 1;
47 + const gap = 1;
48 + const bw = Math.max(1, (width - gap * (n - 1)) / n);
49 + return (
50 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('block shrink-0', className)} role={ariaLabel ? 'img' : undefined} aria-label={ariaLabel} aria-hidden={ariaLabel ? undefined : true}>
51 + {values.map((v, i) => {
52 + const h = Math.max(v > 0 ? 1.5 : 0, (v / max) * (height - 1));
53 + return <rect key={i} x={i * (bw + gap)} y={height - h} width={bw} height={h} fill={color} opacity={v > 0 ? 0.85 : 0.2} rx={0.5} />;
54 + })}
55 + </svg>
56 + );
57 +}
added apps/web/src/components/charts/heatmap.tsx +56 −0
@@ -0,0 +1,56 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { fmtInt } from '@/lib/format';
5 +
6 +/**
7 + * Compact heatmap table (rows × columns) with sequential intensity from the accent hue. Server-renderable.
8 + * Cells with null are drawn hollow, never as 0.
9 + */
10 +export function Heatmap({ rows, cols, get, rowHref, format = fmtInt, className, colLabel = (c) => c }: { rows: { key: string; label: ReactNode }[]; cols: string[]; get: (row: string, col: string) => number | null; rowHref?: (row: string) => string | undefined; format?: (v: number) => string; className?: string; colLabel?: (c: string) => ReactNode }) {
11 + let max = 0;
12 + for (const r of rows) for (const c of cols) max = Math.max(max, get(r.key, c) ?? 0);
13 + return (
14 + <div className={cn('table-scroll', className)}>
15 + <table className="w-full border-separate border-spacing-[2px] text-xs">
16 + <thead>
17 + <tr>
18 + <th className="sticky left-0 z-[2] bg-canvas text-left font-medium text-ink-2" />
19 + {cols.map((c) => (
20 + <th key={c} className="whitespace-nowrap px-1 pb-1 text-center font-medium text-ink-3">
21 + {colLabel(c)}
22 + </th>
23 + ))}
24 + </tr>
25 + </thead>
26 + <tbody>
27 + {rows.map((r) => {
28 + const href = rowHref?.(r.key);
29 + return (
30 + <tr key={r.key}>
31 + <th className="sticky left-0 z-[2] max-w-[10rem] truncate bg-canvas pr-2 text-left font-medium text-ink-2">
32 + {href ? (
33 + <Link href={href} className="hover:text-accent">
34 + {r.label}
35 + </Link>
36 + ) : (
37 + r.label
38 + )}
39 + </th>
40 + {cols.map((c) => {
41 + const v = get(r.key, c);
42 + const a = v === null || max === 0 ? 0 : 0.08 + 0.82 * Math.sqrt(v / max);
43 + return (
44 + <td key={c} className={cn('tnum h-7 min-w-[2.4rem] rounded-[2px] text-center', v === null && 'border border-dashed border-rule text-ink-3')} style={v === null ? undefined : { background: `color-mix(in srgb, var(--accent) ${Math.round(a * 100)}%, var(--surface-2))`, color: a > 0.55 ? 'var(--accent-ink)' : 'var(--ink)' }} title={v === null ? 'no data' : `${format(v)}`}>
45 + {v === null ? '·' : v === 0 ? '' : format(v)}
46 + </td>
47 + );
48 + })}
49 + </tr>
50 + );
51 + })}
52 + </tbody>
53 + </table>
54 + </div>
55 + );
56 +}
added apps/web/src/components/charts/line-chart.tsx +156 −0
@@ -0,0 +1,156 @@
1 +'use client';
2 +import { bisector, extent } from 'd3-array';
3 +import { scaleLinear, scaleTime } from 'd3-scale';
4 +import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape';
5 +import { useId, useMemo, useRef, useState } from 'react';
6 +import { cn } from '@/lib/cn';
7 +import { fmt1, fmtDate, fmtDateShort } from '@/lib/format';
8 +
9 +export type LineSeries = { id: string; label: string; points: { day: string; value: number | null }[]; color?: string };
10 +
11 +/**
12 + * Responsive multi-series time chart (SVG). Hover/touch crosshair with a value readout; y from data (optionally 0-based);
13 + * `baseline` dashed reference (index 100). Colours cycle through `--series-n`. Width follows the container (viewBox).
14 + */
15 +export function LineChart({ series, height = 180, baseline, yZero = false, className, unit = '', showLegend = true, yFormat = fmt1, ariaLabel }: { series: LineSeries[]; height?: number; baseline?: number; yZero?: boolean; className?: string; unit?: string; showLegend?: boolean; yFormat?: (v: number) => string; ariaLabel?: string }) {
16 + const W = 800;
17 + const H = height;
18 + const m = { t: 10, r: 12, b: 22, l: 40 };
19 + const uid = useId();
20 + const ref = useRef<SVGSVGElement>(null);
21 + const [hover, setHover] = useState<number | null>(null);
22 +
23 + const { x, y, dates, parsed } = useMemo(() => {
24 + const parsed = series.map((s) => ({ ...s, pts: s.points.map((p) => ({ t: new Date(p.day.length === 10 ? `${p.day}T00:00:00Z` : p.day).getTime(), v: p.value })).filter((p) => Number.isFinite(p.t)) }));
25 + const allT = parsed.flatMap((s) => s.pts.map((p) => p.t));
26 + const allV = parsed.flatMap((s) => s.pts.map((p) => p.v)).filter((v): v is number => v !== null && Number.isFinite(v));
27 + const [t0, t1] = allT.length ? (extent(allT) as [number, number]) : [Date.now() - 86400000, Date.now()];
28 + let [v0, v1] = allV.length ? (extent([...allV, ...(baseline !== undefined ? [baseline] : [])]) as [number, number]) : [0, 1];
29 + if (yZero) v0 = Math.min(0, v0);
30 + if (v0 === v1) {
31 + v0 -= 1;
32 + v1 += 1;
33 + }
34 + const pad = (v1 - v0) * 0.08;
35 + const x = scaleTime()
36 + .domain([new Date(t0), new Date(t1)])
37 + .range([m.l, W - m.r]);
38 + const y = scaleLinear()
39 + .domain([v0 - (yZero && v0 === 0 ? 0 : pad), v1 + pad])
40 + .range([H - m.b, m.t])
41 + .nice(4);
42 + const dates = [...new Set(allT)].sort((a, b) => a - b);
43 + return { x, y, dates, parsed };
44 + }, [series, baseline, yZero, H, m.b, m.l, m.r, m.t]);
45 +
46 + if (!parsed.some((s) => s.pts.some((p) => p.v !== null))) {
47 + return (
48 + <div className={cn('flex items-center justify-center border border-dashed border-rule-strong text-xs text-ink-3', className)} style={{ height }} role="status">
49 + No series available yet.
50 + </div>
51 + );
52 + }
53 +
54 + const ticksY = y.ticks(4);
55 + const ticksX = x.ticks(Math.min(6, Math.max(2, dates.length)));
56 + const bis = bisector<number, number>((d) => d).center;
57 + const onMove = (clientX: number) => {
58 + const svg = ref.current;
59 + if (!svg) return;
60 + const r = svg.getBoundingClientRect();
61 + const px = ((clientX - r.left) / r.width) * W;
62 + const t = x.invert(px).getTime();
63 + setHover(dates[bis(dates, t)] ?? null);
64 + };
65 + const line = d3line<{ t: number; v: number | null }>()
66 + .defined((d) => d.v !== null)
67 + .x((d) => x(d.t))
68 + .y((d) => y(d.v as number))
69 + .curve(curveMonotoneX);
70 + const area = d3area<{ t: number; v: number | null }>()
71 + .defined((d) => d.v !== null)
72 + .x((d) => x(d.t))
73 + .y0(y.range()[0] as number)
74 + .y1((d) => y(d.v as number))
75 + .curve(curveMonotoneX);
76 + const colorOf = (i: number, c?: string) => c ?? `var(--series-${(i % 6) + 1})`;
77 +
78 + return (
79 + <div className={cn('w-full', className)}>
80 + <svg
81 + ref={ref}
82 + viewBox={`0 0 ${W} ${H}`}
83 + className="block h-auto w-full touch-pan-y select-none"
84 + role="img"
85 + aria-label={ariaLabel ?? `${series.map((s) => s.label).join(', ')} over time`}
86 + onMouseMove={(e) => onMove(e.clientX)}
87 + onMouseLeave={() => setHover(null)}
88 + onTouchStart={(e) => e.touches[0] && onMove(e.touches[0].clientX)}
89 + onTouchMove={(e) => e.touches[0] && onMove(e.touches[0].clientX)}
90 + onTouchEnd={() => setHover(null)}
91 + >
92 + <defs>
93 + <clipPath id={`${uid}-clip`}>
94 + <rect x={m.l} y={0} width={W - m.l - m.r} height={H} />
95 + </clipPath>
96 + </defs>
97 + {ticksY.map((t) => (
98 + <g key={t}>
99 + <line x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />
100 + <text x={m.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum">
101 + {yFormat(t)}
102 + </text>
103 + </g>
104 + ))}
105 + {ticksX.map((t) => (
106 + <text key={+t} x={x(t)} y={H - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">
107 + {fmtDateShort(t.toISOString())}
108 + </text>
109 + ))}
110 + {baseline !== undefined && <line x1={m.l} x2={W - m.r} y1={y(baseline)} y2={y(baseline)} stroke="var(--rule-strong)" strokeDasharray="3 4" />}
111 + <g clipPath={`url(#${uid}-clip)`}>
112 + {parsed.map((s, i) => (
113 + <g key={s.id}>
114 + {parsed.length === 1 && <path d={area(s.pts) ?? ''} fill={colorOf(i, s.color)} opacity={0.08} />}
115 + <path d={line(s.pts) ?? ''} fill="none" stroke={colorOf(i, s.color)} strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round" />
116 + </g>
117 + ))}
118 + </g>
119 + {hover !== null && (
120 + <g>
121 + <line x1={x(hover)} x2={x(hover)} y1={m.t} y2={H - m.b} stroke="var(--ink-3)" strokeDasharray="2 3" />
122 + {parsed.map((s, i) => {
123 + const p = s.pts.find((q) => q.t === hover);
124 + if (!p || p.v === null) return null;
125 + return <circle key={s.id} cx={x(p.t)} cy={y(p.v)} r={3} fill={colorOf(i, s.color)} stroke="var(--canvas)" strokeWidth={1.5} />;
126 + })}
127 + </g>
128 + )}
129 + </svg>
130 + <div className="mt-1 flex min-h-5 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">
131 + {hover !== null ? (
132 + <>
133 + <span className="tnum text-ink-2">{fmtDate(new Date(hover).toISOString())}</span>
134 + {parsed.map((s, i) => {
135 + const p = s.pts.find((q) => q.t === hover);
136 + return (
137 + <span key={s.id} className="inline-flex items-center gap-1.5">
138 + <span className="inline-block size-2 rounded-full" style={{ background: colorOf(i, s.color) }} />
139 + {s.label} <span className="tnum font-medium text-ink">{p && p.v !== null ? `${yFormat(p.v)}${unit}` : '—'}</span>
140 + </span>
141 + );
142 + })}
143 + </>
144 + ) : (
145 + showLegend &&
146 + parsed.map((s, i) => (
147 + <span key={s.id} className="inline-flex items-center gap-1.5">
148 + <span className="inline-block size-2 rounded-full" style={{ background: colorOf(i, s.color) }} />
149 + {s.label}
150 + </span>
151 + ))
152 + )}
153 + </div>
154 + </div>
155 + );
156 +}
added apps/web/src/components/charts/sparkline.tsx +91 −0
@@ -0,0 +1,91 @@
1 +import { extent, max, min } from 'd3-array';
2 +import { scaleLinear } from 'd3-scale';
3 +import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape';
4 +import { cn } from '@/lib/cn';
5 +
6 +/**
7 + * Inline sparkline (SVG, server-renderable). Values are plotted left→right; nulls break the line. `baseline` draws a
8 + * dashed reference (e.g. index 100). `tone` colours by trend when 'auto'. Never fabricates: empty → a flat hairline.
9 + */
10 +export function Sparkline({
11 + values,
12 + width = 96,
13 + height = 24,
14 + className,
15 + tone = 'auto',
16 + fill = true,
17 + baseline,
18 + strokeWidth = 1.4,
19 + ariaLabel,
20 +}: {
21 + values: Array<number | null | undefined> | undefined | null;
22 + width?: number;
23 + height?: number;
24 + className?: string;
25 + tone?: 'auto' | 'accent' | 'positive' | 'negative' | 'muted' | 'ink';
26 + fill?: boolean;
27 + baseline?: number;
28 + strokeWidth?: number;
29 + ariaLabel?: string;
30 +}) {
31 + const pts = (values ?? []).map((v, i) => [i, typeof v === 'number' && Number.isFinite(v) ? v : null] as [number, number | null]);
32 + const valid = pts.filter((p): p is [number, number] => p[1] !== null);
33 + if (valid.length < 2) {
34 + return (
35 + <svg width={width} height={height} className={cn('block shrink-0', className)} aria-hidden>
36 + <line x1={0} x2={width} y1={height / 2} y2={height / 2} stroke="var(--rule-strong)" strokeDasharray="2 3" />
37 + </svg>
38 + );
39 + }
40 + const first = valid[0]?.[1] ?? 0;
41 + const last = valid[valid.length - 1]?.[1] ?? 0;
42 + const color =
43 + tone === 'auto' ? (last > first ? 'var(--positive)' : last < first ? 'var(--danger)' : 'var(--ink-3)') : tone === 'accent' ? 'var(--accent)' : tone === 'positive' ? 'var(--positive)' : tone === 'negative' ? 'var(--danger)' : tone === 'ink' ? 'var(--ink)' : 'var(--ink-3)';
44 + const x = scaleLinear()
45 + .domain([0, pts.length - 1])
46 + .range([1, width - 1]);
47 + const ys = valid.map((p) => p[1]);
48 + let [lo, hi] = extent([...ys, ...(baseline !== undefined ? [baseline] : [])]) as [number, number];
49 + if (lo === hi) {
50 + lo -= 1;
51 + hi += 1;
52 + }
53 + const y = scaleLinear()
54 + .domain([lo, hi])
55 + .range([height - 2, 2]);
56 + const l = d3line<[number, number | null]>()
57 + .defined((d) => d[1] !== null)
58 + .x((d) => x(d[0]))
59 + .y((d) => y(d[1] as number))
60 + .curve(curveMonotoneX);
61 + const a = d3area<[number, number | null]>()
62 + .defined((d) => d[1] !== null)
63 + .x((d) => x(d[0]))
64 + .y0(height)
65 + .y1((d) => y(d[1] as number))
66 + .curve(curveMonotoneX);
67 + const lastPt = valid[valid.length - 1] as [number, number];
68 + return (
69 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('block shrink-0 overflow-visible', className)} role={ariaLabel ? 'img' : undefined} aria-label={ariaLabel} aria-hidden={ariaLabel ? undefined : true}>
70 + {fill && <path d={a(pts) ?? ''} fill={color} opacity={0.1} />}
71 + {baseline !== undefined && <line x1={0} x2={width} y1={y(baseline)} y2={y(baseline)} stroke="var(--rule-strong)" strokeDasharray="2 3" />}
72 + <path d={l(pts) ?? ''} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" />
73 + <circle cx={x(lastPt[0])} cy={y(lastPt[1])} r={1.8} fill={color} />
74 + </svg>
75 + );
76 +}
77 +
78 +/** Delta helper: first vs last of a series (percentage), null when not computable. */
79 +export function sparkDelta(values: Array<number | null | undefined> | undefined | null): number | null {
80 + const v = (values ?? []).filter((x): x is number => typeof x === 'number' && Number.isFinite(x));
81 + if (v.length < 2) return null;
82 + const a = v[0] as number;
83 + const b = v[v.length - 1] as number;
84 + if (a === 0) return null;
85 + return ((b - a) / Math.abs(a)) * 100;
86 +}
87 +
88 +export function seriesMinMax(values: number[]): [number, number] | null {
89 + if (!values.length) return null;
90 + return [min(values) ?? 0, max(values) ?? 0];
91 +}
added apps/web/src/components/charts/world-map.tsx +121 −0
@@ -0,0 +1,121 @@
1 +'use client';
2 +import { geoNaturalEarth1, geoPath } from 'd3-geo';
3 +import { scaleSqrt } from 'd3-scale';
4 +import { useRouter } from 'next/navigation';
5 +import { useMemo, useState } from 'react';
6 +import { feature } from 'topojson-client';
7 +import type { GeometryCollection, Topology } from 'topojson-specification';
8 +import world from 'world-atlas/countries-110m.json';
9 +import { cn } from '@/lib/cn';
10 +import { alpha2FromNumeric, countryName } from '@/lib/countries';
11 +import { fmtInt } from '@/lib/format';
12 +import type { MapBucket } from '@/lib/types';
13 +
14 +const W = 960;
15 +const H = 470;
16 +
17 +type Metric = 'events_30d' | 'companies' | 'jobs_open';
18 +
19 +/**
20 + * Global Activity Map: Natural Earth projection over the offline `world-atlas` 110m TopoJSON, bubbles from `/map` buckets
21 + * sized by the chosen metric (sqrt scale). Hover shows a tooltip; clicking a bubble or a country opens `/country/<code>`.
22 + */
23 +export function WorldMap({ buckets, metric = 'events_30d', className, height, interactive = true, highlight }: { buckets: MapBucket[]; metric?: Metric; className?: string; height?: number; interactive?: boolean; highlight?: string[] }) {
24 + const router = useRouter();
25 + const [tip, setTip] = useState<{ x: number; y: number; b: MapBucket } | null>(null);
26 + const [hoverCountry, setHoverCountry] = useState<string | null>(null);
27 +
28 + const { land, path, projection } = useMemo(() => {
29 + const topo = world as unknown as Topology<{ countries: GeometryCollection<{ name: string }> }>;
30 + const fc = feature(topo, topo.objects.countries);
31 + const projection = geoNaturalEarth1().fitExtent(
32 + [
33 + [4, 4],
34 + [W - 4, H - 4],
35 + ],
36 + { type: 'Sphere' },
37 + );
38 + const path = geoPath(projection);
39 + return { land: fc.features, path, projection };
40 + }, []);
41 +
42 + const r = useMemo(() => {
43 + const max = Math.max(1, ...buckets.map((b) => b[metric] ?? 0));
44 + return scaleSqrt().domain([0, max]).range([0, 26]);
45 + }, [buckets, metric]);
46 +
47 + const placed = useMemo(
48 + () =>
49 + buckets
50 + .map((b) => {
51 + const p = projection([b.lon, b.lat]);
52 + return p ? { b, x: p[0], y: p[1], rr: r(b[metric] ?? 0) } : null;
53 + })
54 + .filter((x): x is { b: MapBucket; x: number; y: number; rr: number } => x !== null && x.rr > 0)
55 + .sort((a, b) => b.rr - a.rr),
56 + [buckets, projection, r, metric],
57 + );
58 +
59 + const hl = new Set((highlight ?? []).map((c) => c.toUpperCase()));
60 + const go = (code: string | null) => {
61 + if (interactive && code) router.push(`/country/${code.toLowerCase()}`);
62 + };
63 +
64 + return (
65 + <div className={cn('relative w-full', className)}>
66 + <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full" style={height ? { maxHeight: height } : undefined} role="img" aria-label="World map of monitored company activity">
67 + <path d={path({ type: 'Sphere' }) ?? ''} fill="var(--surface)" stroke="var(--rule)" />
68 + {land.map((f, li) => {
69 + const code = alpha2FromNumeric(f.id as string | number | undefined);
70 + const on = code !== null && (hoverCountry === code || hl.has(code));
71 + return (
72 + <path
73 + key={`${String(f.id)}-${li}`}
74 + d={path(f) ?? ''}
75 + fill={on ? 'var(--accent-soft)' : 'var(--map-land)'}
76 + stroke="var(--map-stroke)"
77 + strokeWidth={0.5}
78 + className={cn(interactive && code && 'cursor-pointer')}
79 + onMouseEnter={() => setHoverCountry(code)}
80 + onMouseLeave={() => setHoverCountry(null)}
81 + onClick={() => go(code)}
82 + >
83 + <title>{f.properties?.name ?? countryName(code)}</title>
84 + </path>
85 + );
86 + })}
87 + {placed.map(({ b, x, y, rr }, i) => (
88 + <g key={`${b.country}-${b.city ?? ''}-${i}`} transform={`translate(${x},${y})`} className={cn(interactive && 'cursor-pointer')} onMouseEnter={() => setTip({ x, y, b })} onMouseLeave={() => setTip(null)} onFocus={() => setTip({ x, y, b })} onBlur={() => setTip(null)} onClick={() => go(b.country)}>
89 + <title>{`${b.city ?? countryName(b.country)}: ${fmtInt(b[metric])} ${metricLabel(metric)}`}</title>
90 + <circle r={rr} fill="var(--map-bubble)" stroke="var(--map-bubble-stroke)" strokeWidth={0.8} />
91 + {rr > 8 && <circle r={1.6} fill="var(--map-bubble-stroke)" />}
92 + </g>
93 + ))}
94 + {tip && (
95 + <g transform={`translate(${Math.min(W - 190, Math.max(6, tip.x + 12))},${Math.max(6, Math.min(H - 70, tip.y - 20))})`} pointerEvents="none">
96 + <rect width={180} height={tip.b.top.length ? 62 : 44} rx={4} fill="var(--surface)" stroke="var(--rule-strong)" />
97 + <text x={10} y={17} fontSize={12} fontWeight={600} fill="var(--ink)">
98 + {tip.b.city ? `${tip.b.city}, ${tip.b.country}` : countryName(tip.b.country)}
99 + </text>
100 + <text x={10} y={33} fontSize={11} fill="var(--ink-2)" className="tnum">
101 + {fmtInt(tip.b.companies)} companies · {fmtInt(tip.b.events_30d)} events 30d · {fmtInt(tip.b.jobs_open)} jobs
102 + </text>
103 + {tip.b.top.length > 0 && (
104 + <text x={10} y={50} fontSize={11} fill="var(--ink-3)">
105 + {tip.b.top
106 + .slice(0, 3)
107 + .map((t) => t.display_name)
108 + .join(' · ')
109 + .slice(0, 34)}
110 + </text>
111 + )}
112 + </g>
113 + )}
114 + </svg>
115 + </div>
116 + );
117 +}
118 +
119 +function metricLabel(m: Metric): string {
120 + return m === 'events_30d' ? 'events in 30 days' : m === 'companies' ? 'companies' : 'open jobs';
121 +}
added apps/web/src/components/company/company-header.tsx +87 −0
@@ -0,0 +1,87 @@
1 +import { ExternalLink, GitCompareArrows } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { Chip, CompanyTierBadge, CountryChip, StatusBadge } from '@/components/ui/badges';
4 +import { LiveAgo } from '@/components/ui/live';
5 +import { countryName } from '@/lib/countries';
6 +import { fmtInt, fmtPct, plural } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { CompanyDetail } from '@/lib/types';
9 +import { WatchButton } from './watch-button';
10 +
11 +/** Company identity block: name, domain, ISO chip, industries, ticker, tier, status, watch + compare. */
12 +export function CompanyHeader({ c }: { c: CompanyDetail }) {
13 + return (
14 + <div className="pb-4 pt-6 md:pt-8">
15 + <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
16 + <div className="min-w-0">
17 + <div className="eyebrow flex flex-wrap items-center gap-2">
18 + <span>Company</span>
19 + <CompanyTierBadge tier={c.tier} />
20 + {c.status !== 'ACTIVE' && <StatusBadge status={c.status} />}
21 + {c.public_company && c.ticker && (
22 + <span className="mono normal-case tracking-normal text-ink-2">
23 + {c.ticker}
24 + {c.exchange ? ` · ${c.exchange}` : ''}
25 + </span>
26 + )}
27 + </div>
28 + <h1 className="display mt-2 text-[28px] md:text-[40px]">{c.display_name}</h1>
29 + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-ink-2">
30 + <a href={c.website} target="_blank" rel="noopener noreferrer" className="mono inline-flex items-center gap-1 text-ink-2 hover:text-accent">
31 + {c.canonical_domain} <ExternalLink className="size-3" aria-hidden />
32 + </a>
33 + <span className="inline-flex items-center gap-1.5">
34 + <CountryChip code={c.country} name={countryName(c.country)} />
35 + {c.hq_city && <span>{c.hq_city}</span>}
36 + </span>
37 + {c.founded_year && <span className="tnum">est. {c.founded_year}</span>}
38 + {c.employees_band && <span className="tnum">{c.employees_band} employees (stated)</span>}
39 + </div>
40 + <div className="mt-2 flex flex-wrap gap-1.5">
41 + {c.industries.map((i) => (
42 + <Link key={i} href={routes.industry(i)}>
43 + <Chip tone="neutral" className="hover:bg-surface-3">
44 + {i.replace(/-/g, ' ')}
45 + </Chip>
46 + </Link>
47 + ))}
48 + </div>
49 + {c.description && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">{c.description}</p>}
50 + </div>
51 + <div className="flex shrink-0 flex-wrap items-center gap-2 md:flex-col md:items-end">
52 + <div className="flex gap-2">
53 + <WatchButton slug={c.slug} name={c.display_name} />
54 + <Link href={routes.compare([c.slug])} className="btn">
55 + <GitCompareArrows className="size-4" aria-hidden /> Compare
56 + </Link>
57 + </div>
58 + <p className="text-xs text-ink-3">
59 + last observed <LiveAgo at={c.last_observed_at} tick={30000} />
60 + </p>
61 + </div>
62 + </div>
63 + </div>
64 + );
65 +}
66 +
67 +/** "32 active sensors · 18,420 observations · 786 historical changes · 249 structured events · historical coverage 94 %" */
68 +export function DensityStrip({ c }: { c: CompanyDetail }) {
69 + const parts: string[] = [
70 + `${fmtInt(c.counts.sensors)} active ${plural(c.counts.sensors, 'sensor')}`,
71 + `${fmtInt(c.counts.observations)} ${plural(c.counts.observations, 'observation')}`,
72 + `${fmtInt(c.counts.changes)} historical ${plural(c.counts.changes, 'change')}`,
73 + `${fmtInt(c.counts.events)} structured ${plural(c.counts.events, 'event')}`,
74 + ];
75 + if (c.coverage?.historical_coverage !== null && c.coverage?.historical_coverage !== undefined) parts.push(`historical coverage ${fmtPct(c.coverage.historical_coverage, 0)}`);
76 + if (c.coverage?.first_observed_at) parts.push(`observed since ${new Date(c.coverage.first_observed_at).toISOString().slice(0, 10)}`);
77 + return (
78 + <p className="tnum border-y border-rule py-2 text-xs text-ink-2" data-density-strip>
79 + {parts.map((p, i) => (
80 + <span key={p}>
81 + {i > 0 && <span className="mx-1.5 text-ink-3">·</span>}
82 + {p}
83 + </span>
84 + ))}
85 + </p>
86 + );
87 +}
added apps/web/src/components/company/company-table.tsx +164 −0
@@ -0,0 +1,164 @@
1 +import Link from 'next/link';
2 +import { Sparkline } from '@/components/charts/sparkline';
3 +import { CompanyTierBadge, CountryChip } from '@/components/ui/badges';
4 +import { LiveAgo } from '@/components/ui/live';
5 +import { cn } from '@/lib/cn';
6 +import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { CompanyCard } from '@/lib/types';
9 +
10 +/**
11 + * Directory rows: dense table on ≥ md (name · country · industry · activity · hiring 30d · AI · events · sensors · sparkline ·
12 + * last event) and stacked cards below. Missing metrics render as a dash, never 0.
13 + */
14 +export function CompanyTable({ items, className, rank = false, showSparkline = true, valueLabel, formatValue, formatDelta }: { items: (CompanyCard & { rank?: number; value?: number; delta?: number | null })[]; className?: string; rank?: boolean; showSparkline?: boolean; valueLabel?: string; formatValue?: (c: CompanyCard & { value?: number }) => string; formatDelta?: (c: CompanyCard & { delta?: number | null }) => string | null }) {
15 + if (!items.length) return <p className="border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">No companies match these filters yet.</p>;
16 + return (
17 + <div className={className} data-company-table>
18 + {/* desktop */}
19 + <div className="table-scroll hidden md:block">
20 + <table className="data-table">
21 + <thead>
22 + <tr>
23 + {rank && <th className="num">#</th>}
24 + <th>Company</th>
25 + <th>Country</th>
26 + <th>Industry</th>
27 + {valueLabel && <th className="num">{valueLabel}</th>}
28 + {valueLabel && formatDelta && <th className="num">Δ</th>}
29 + <th className="num">Activity</th>
30 + <th className="num">Hiring 30d</th>
31 + <th className="num">AI</th>
32 + <th className="num">Events</th>
33 + <th className="num">Sensors</th>
34 + {showSparkline && <th>30 d</th>}
35 + <th className="num">Last event</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + {items.map((c) => {
40 + const h = num(c.metrics.hiring_momentum_30d);
41 + const d = formatDelta?.(c) ?? null;
42 + return (
43 + <tr key={c.id}>
44 + {rank && <td className="num tnum text-ink-3">{c.rank}</td>}
45 + <td className="primary">
46 + <Link href={routes.company(c.slug)} className="row-link">
47 + <span className="inline-flex items-center gap-2">
48 + {c.display_name}
49 + <CompanyTierBadge tier={c.tier} />
50 + </span>
51 + <span className="mono block text-[11px] font-normal text-ink-3">{c.canonical_domain}</span>
52 + </Link>
53 + </td>
54 + <td>
55 + <CountryChip code={c.country} />
56 + </td>
57 + <td className="text-ink-2">{c.industry_primary ? <Link href={routes.industry(c.industry_primary)} className="hover:text-accent">{c.industry_primary.replace(/-/g, ' ')}</Link> : '—'}</td>
58 + {valueLabel && <td className="num tnum font-medium">{formatValue ? formatValue(c) : fmtScore(c.value)}</td>}
59 + {valueLabel && formatDelta && <td className={cn('num tnum text-xs', d?.startsWith('+') ? 'text-positive' : d?.startsWith('−') ? 'text-danger' : 'text-ink-3')}>{d ?? '—'}</td>}
60 + <td className="num tnum">{fmtScore(c.metrics.activity_score)}</td>
61 + <td className={cn('num tnum', h !== null && (h > 0 ? 'text-positive' : h < 0 ? 'text-danger' : ''))}>{h === null ? '—' : fmtPctSigned(h)}</td>
62 + <td className="num tnum">{fmtScore(c.metrics.ai_adoption)}</td>
63 + <td className="num tnum">{fmtInt(c.counts.events)}</td>
64 + <td className="num tnum">{fmtInt(c.counts.sensors)}</td>
65 + {showSparkline && (
66 + <td>
67 + <Sparkline values={c.sparkline} width={80} height={20} tone="accent" />
68 + </td>
69 + )}
70 + <td className="num text-xs text-ink-3">
71 + <LiveAgo at={c.last_event_at} tick={30000} absoluteFallback={false} />
72 + </td>
73 + </tr>
74 + );
75 + })}
76 + </tbody>
77 + </table>
78 + </div>
79 + {/* mobile cards */}
80 + <ul className="divide-y divide-rule border-y border-rule md:hidden">
81 + {items.map((c) => {
82 + const h = num(c.metrics.hiring_momentum_30d);
83 + return (
84 + <li key={c.id}>
85 + <Link href={routes.company(c.slug)} className="block py-3">
86 + <div className="flex items-start gap-3">
87 + {rank && <span className="tnum w-6 shrink-0 pt-0.5 text-sm text-ink-3">{c.rank}</span>}
88 + <div className="min-w-0 flex-1">
89 + <p className="flex items-center gap-2 text-[15px] font-medium text-ink">
90 + <span className="truncate">{c.display_name}</span>
91 + <CountryChip code={c.country} link={false} />
92 + </p>
93 + <p className="mono truncate text-[11px] text-ink-3">
94 + {c.canonical_domain}
95 + {c.industry_primary ? ` · ${c.industry_primary.replace(/-/g, ' ')}` : ''}
96 + </p>
97 + </div>
98 + {showSparkline && <Sparkline values={c.sparkline} width={64} height={22} tone="accent" />}
99 + </div>
100 + <dl className="tnum mt-2 grid grid-cols-4 gap-2 text-center text-xs">
101 + {valueLabel ? (
102 + <div>
103 + <dt className="text-[10px] uppercase tracking-wider text-ink-3">{valueLabel}</dt>
104 + <dd className="font-medium text-ink">{formatValue ? formatValue(c) : fmtScore(c.value)}</dd>
105 + </div>
106 + ) : (
107 + <div>
108 + <dt className="text-[10px] uppercase tracking-wider text-ink-3">Activity</dt>
109 + <dd className="font-medium text-ink">{fmtScore(c.metrics.activity_score)}</dd>
110 + </div>
111 + )}
112 + <div>
113 + <dt className="text-[10px] uppercase tracking-wider text-ink-3">Hiring</dt>
114 + <dd className={cn('font-medium', h === null ? 'text-ink-3' : h > 0 ? 'text-positive' : h < 0 ? 'text-danger' : 'text-ink')}>{h === null ? '—' : fmtPctSigned(h, 0)}</dd>
115 + </div>
116 + <div>
117 + <dt className="text-[10px] uppercase tracking-wider text-ink-3">Events</dt>
118 + <dd className="font-medium text-ink">{fmtInt(c.counts.events)}</dd>
119 + </div>
120 + <div>
121 + <dt className="text-[10px] uppercase tracking-wider text-ink-3">Sensors</dt>
122 + <dd className="font-medium text-ink">{fmtInt(c.counts.sensors)}</dd>
123 + </div>
124 + </dl>
125 + </Link>
126 + </li>
127 + );
128 + })}
129 + </ul>
130 + </div>
131 + );
132 +}
133 +
134 +/** Compact list of companies (name · country · one metric) for homepage sections and side panels. */
135 +export function CompanyMiniList({ items, metric = 'activity_score', label, format, className, sparkline = false }: { items: CompanyCard[]; metric?: keyof CompanyCard['metrics']; label?: string; format?: (v: number | null) => string; className?: string; sparkline?: boolean }) {
136 + if (!items.length) return <p className="border border-dashed border-rule-strong px-4 py-6 text-center text-xs text-ink-3">No monitored evidence available yet.</p>;
137 + const fmt = format ?? ((v: number | null) => (v === null ? '—' : metric.startsWith('hiring') ? fmtPctSigned(v) : fmtScore(v)));
138 + return (
139 + <ol className={cn('divide-y divide-rule border-y border-rule', className)}>
140 + {items.map((c, i) => {
141 + const v = num(c.metrics[metric]);
142 + return (
143 + <li key={c.id}>
144 + <Link href={routes.company(c.slug)} className="flex items-center gap-3 py-2 hover:text-accent">
145 + <span className="tnum w-5 shrink-0 text-xs text-ink-3">{i + 1}</span>
146 + <span className="min-w-0 flex-1">
147 + <span className="block truncate text-[14px] font-medium text-ink">{c.display_name}</span>
148 + <span className="mono block truncate text-[11px] text-ink-3">
149 + {c.canonical_domain}
150 + {c.country ? ` · ${c.country}` : ''}
151 + </span>
152 + </span>
153 + {sparkline && <Sparkline values={c.sparkline} width={56} height={18} tone={metric.startsWith('hiring') ? 'auto' : 'accent'} />}
154 + <span className="text-right">
155 + <span className={cn('tnum block text-[14px] font-medium', v !== null && metric.startsWith('hiring') && (v > 0 ? 'text-positive' : v < 0 ? 'text-danger' : ''))}>{fmt(v)}</span>
156 + {label && <span className="block text-[10px] uppercase tracking-wider text-ink-3">{label}</span>}
157 + </span>
158 + </Link>
159 + </li>
160 + );
161 + })}
162 + </ol>
163 + );
164 +}
added apps/web/src/components/company/compare-picker.tsx +74 −0
@@ -0,0 +1,74 @@
1 +'use client';
2 +import { Plus, X } from 'lucide-react';
3 +import { useRouter } from 'next/navigation';
4 +import { useEffect, useState } from 'react';
5 +import { clientApi } from '@/lib/client-api';
6 +import { routes } from '@/lib/site';
7 +import type { Suggestion } from '@/lib/types';
8 +
9 +/** Add/remove companies on /company/compare via `/search/suggest` (2–6 slugs in the URL). */
10 +export function ComparePicker({ slugs, names }: { slugs: string[]; names: Record<string, string> }) {
11 + const router = useRouter();
12 + const [q, setQ] = useState('');
13 + const [items, setItems] = useState<Suggestion[]>([]);
14 + useEffect(() => {
15 + if (q.trim().length < 1) {
16 + setItems([]);
17 + return;
18 + }
19 + const ctrl = new AbortController();
20 + const t = setTimeout(() => {
21 + clientApi
22 + .suggest(q.trim(), ctrl.signal)
23 + .then((r) => setItems(r.items.filter((s) => s.kind === 'company')))
24 + .catch(() => setItems([]));
25 + }, 120);
26 + return () => {
27 + clearTimeout(t);
28 + ctrl.abort();
29 + };
30 + }, [q]);
31 + const slugOf = (href: string) => href.replace(/^\/company\//, '').split('?')[0] ?? '';
32 + const add = (s: string) => {
33 + if (!s || slugs.includes(s) || slugs.length >= 6) return;
34 + router.push(routes.compare([...slugs, s]));
35 + setQ('');
36 + setItems([]);
37 + };
38 + const remove = (s: string) => router.push(routes.compare(slugs.filter((x) => x !== s)));
39 + return (
40 + <div className="flex flex-wrap items-center gap-2" data-compare-picker>
41 + {slugs.map((s) => (
42 + <span key={s} className="inline-flex h-10 items-center gap-1 border border-rule bg-surface pl-2.5 pr-0.5 text-sm">
43 + {names[s] ?? s}
44 + <button type="button" onClick={() => remove(s)} className="flex size-10 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Remove ${names[s] ?? s}`}>
45 + <X className="size-3.5" aria-hidden />
46 + </button>
47 + </span>
48 + ))}
49 + {slugs.length < 6 && (
50 + <div className="relative">
51 + <label className="sr-only" htmlFor="compare-add">
52 + Add a company
53 + </label>
54 + <span className="flex min-h-10 items-center gap-1 border border-rule-strong bg-surface px-2">
55 + <Plus className="size-4 text-ink-3" aria-hidden />
56 + <input id="compare-add" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Add company…" className="h-10 w-40 bg-transparent text-sm focus:outline-none" autoComplete="off" data-compare-input onKeyDown={(e) => e.key === 'Enter' && items[0] && add(slugOf(items[0].href))} />
57 + </span>
58 + {items.length > 0 && (
59 + <ul className="panel absolute left-0 top-full z-20 mt-1 w-64 py-1 shadow-lg" role="listbox">
60 + {items.slice(0, 8).map((s) => (
61 + <li key={s.href}>
62 + <button type="button" onClick={() => add(slugOf(s.href))} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-surface-2" role="option" aria-selected={false}>
63 + <span className="flex-1 truncate">{s.label}</span>
64 + <span className="truncate text-xs text-ink-3">{s.sublabel}</span>
65 + </button>
66 + </li>
67 + ))}
68 + </ul>
69 + )}
70 + </div>
71 + )}
72 + </div>
73 + );
74 +}
added apps/web/src/components/company/metric-tiles.tsx +41 −0
@@ -0,0 +1,41 @@
1 +import Link from 'next/link';
2 +import { Sparkline } from '@/components/charts/sparkline';
3 +import { cn } from '@/lib/cn';
4 +import { fmtPctSigned, fmtScore, num } from '@/lib/format';
5 +import { METRIC_LABELS } from '@/lib/site';
6 +import type { CompanyDetail, Metric, MetricDetail } from '@/lib/types';
7 +
8 +const TILES: { metric: Metric; spark?: 'activity_30d' | 'hiring_90d'; pct?: boolean }[] = [
9 + { metric: 'activity_score', spark: 'activity_30d' },
10 + { metric: 'hiring_momentum_30d', spark: 'hiring_90d', pct: true },
11 + { metric: 'product_velocity' },
12 + { metric: 'ai_adoption' },
13 + { metric: 'corporate_change_index' },
14 +];
15 +
16 +/**
17 + * Metric tiles with sparklines and confidence. A metric with no value is shown as "not enough evidence" rather than 0
18 + * (API omits metrics without inputs — spec §165). Each tile links to the methodology.
19 + */
20 +export function MetricTiles({ c, className }: { c: CompanyDetail; className?: string }) {
21 + const detail = new Map<string, MetricDetail>((c.metrics_detail ?? []).map((m) => [m.metric, m]));
22 + return (
23 + <div className={cn('grid grid-cols-2 gap-x-6 border-y border-rule md:grid-cols-5 [&>*]:border-b [&>*]:border-rule md:[&>*]:border-b-0', className)} data-metric-tiles>
24 + {TILES.map((t) => {
25 + const v = num(c.metrics[t.metric]);
26 + const d = detail.get(t.metric);
27 + const spark = t.spark ? c.sparklines?.[t.spark] : undefined;
28 + return (
29 + <Link key={t.metric} href={`/methodology#${t.metric}`} className="group block min-w-0 py-3 hover:text-accent md:py-4">
30 + <p className="eyebrow">{METRIC_LABELS[t.metric]}</p>
31 + <div className="mt-1 flex items-end justify-between gap-2">
32 + <p className={cn('tnum whitespace-nowrap text-[22px] font-semibold leading-none tracking-tight md:text-[30px]', v === null && 'text-ink-3', t.pct && v !== null && (v > 0 ? 'text-positive' : v < 0 ? 'text-danger' : ''))}>{v === null ? '—' : t.pct ? fmtPctSigned(v) : fmtScore(v)}</p>
33 + {spark && spark.length > 1 && <Sparkline values={spark} width={64} height={22} tone={t.pct ? 'auto' : 'accent'} className="max-[400px]:hidden" />}
34 + </div>
35 + <p className="mt-1.5 text-[11px] text-ink-3">{v === null ? 'not enough monitored evidence' : d ? `confidence ${Math.round(d.confidence * 100)} %${d.formula_version ? ` · ${d.formula_version}` : ''}` : t.pct ? 'vs 30 days ago' : '0–100'}</p>
36 + </Link>
37 + );
38 + })}
39 + </div>
40 + );
41 +}
added apps/web/src/components/company/panels.tsx +531 −0
@@ -0,0 +1,531 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { Bars } from '@/components/charts/bars';
4 +import { EventList } from '@/components/events/event-row';
5 +import { Chip, ConfidenceBadge, SensorTierBadge, StatusBadge, CountryChip } from '@/components/ui/badges';
6 +import { LiveAgo } from '@/components/ui/live';
7 +import { Empty, Note, Stat, StatGrid } from '@/components/ui/section';
8 +import { cn } from '@/lib/cn';
9 +import { countryName } from '@/lib/countries';
10 +import { fmtDate, fmtDateTime, fmtDayLabel, fmtDuration, fmtInt, fmtPct, fmtPrice, fmtScore, humanize, pathOf, plural } from '@/lib/format';
11 +import { routes, SURFACE_LABELS, TIMELINE_FILTERS } from '@/lib/site';
12 +import type { HistoryPayload, Job, JobsPage, Location, Person, Plan, Product, Sensor, Signal, TimelinePayload } from '@/lib/types';
13 +
14 +/* ------------------------------------------------------------------------------------------------------------ timeline */
15 +export function TimelinePanel({ slug, data, filter }: { slug: string; data: TimelinePayload | null; filter: string }) {
16 + const groups = new Map<string, TimelinePayload['items']>();
17 + for (const e of data?.items ?? []) {
18 + const d = e.day ?? e.detected_at.slice(0, 10);
19 + if (!groups.has(d)) groups.set(d, []);
20 + groups.get(d)!.push(e);
21 + }
22 + const days = [...groups.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1));
23 + return (
24 + <div>
25 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-2 md:mx-0 md:flex-wrap md:px-0" role="tablist" aria-label="Timeline filter">
26 + {TIMELINE_FILTERS.map((f) => (
27 + <Link key={f} href={`${routes.company(slug, 'timeline')}${f === 'all' ? '' : `&filter=${f}`}`} scroll={false} className="chip-btn" data-on={filter === f} role="tab" aria-selected={filter === f}>
28 + {f === 'all' ? 'All' : humanize(f)}
29 + </Link>
30 + ))}
31 + </div>
32 + {!data ? (
33 + <Empty title="Timeline temporarily unavailable." />
34 + ) : days.length === 0 ? (
35 + <Empty>
36 + No {filter === 'all' ? '' : `${filter} `}events detected for this company yet — sensors are attached and observing.
37 + </Empty>
38 + ) : (
39 + <div className="mt-2 space-y-6">
40 + {days.map(([day, items]) => (
41 + <section key={day} aria-label={day}>
42 + <h3 className="sticky top-[calc(var(--header-h)+44px)] z-10 -mx-4 flex items-baseline gap-2 bg-canvas/95 px-4 py-1.5 text-sm font-semibold backdrop-blur md:static md:mx-0 md:px-0">
43 + {fmtDayLabel(day)} <span className="tnum text-xs font-normal text-ink-3">{items.length} {plural(items.length, 'event')}</span>
44 + </h3>
45 + <EventList events={items} variant="timeline" showCompany={false} />
46 + </section>
47 + ))}
48 + </div>
49 + )}
50 + </div>
51 + );
52 +}
53 +
54 +/* ------------------------------------------------------------------------------------------------------------ signals */
55 +export function SignalsPanel({ signals }: { signals: Signal[] }) {
56 + if (!signals.length) return <Empty title="No signals detected for this company yet.">Signals are pattern detections over the company’s own baselines and are labelled as such — never as facts.</Empty>;
57 + return (
58 + <ul className="divide-y divide-rule border-y border-rule">
59 + {signals.map((s) => (
60 + <li key={s.id} className="py-3">
61 + <div className="flex flex-wrap items-center gap-2">
62 + <Chip tone="accent">signal</Chip>
63 + <span className="text-[15px] font-medium text-ink">{s.title}</span>
64 + <span className="ml-auto text-xs text-ink-3">
65 + strength <span className="tnum text-ink-2">{Math.round(s.strength * 100)}</span> · confidence <span className="tnum text-ink-2">{Math.round(s.confidence * 100)} %</span> · {s.window_days} d window
66 + </span>
67 + </div>
68 + {s.explanation && <p className="mt-1 text-sm text-ink-2">{s.explanation}</p>}
69 + <p className="mt-1 text-[11px] text-ink-3">detected {fmtDateTime(s.detected_at)}</p>
70 + </li>
71 + ))}
72 + </ul>
73 + );
74 +}
75 +
76 +/* ------------------------------------------------------------------------------------------------------------ jobs */
77 +export function JobsPanel({ slug, data, status, ai }: { slug: string; data: JobsPage | null; status: string; ai: boolean }) {
78 + const summary = data?.meta?.summary ?? data?.summary;
79 + const base = routes.company(slug, 'jobs');
80 + const link = (patch: { status?: string; ai?: boolean }) => {
81 + const st = patch.status ?? status;
82 + const a = patch.ai ?? ai;
83 + return `${base}${st !== 'open' ? `&status=${st}` : ''}${a ? '&ai=1' : ''}`;
84 + };
85 + return (
86 + <div className="space-y-5">
87 + {summary ? (
88 + <StatGrid cols={5}>
89 + <Stat label="Open listings" value={fmtInt(summary.open)} size="sm" />
90 + <Stat label="New (7 d)" value={fmtInt(summary.new_7d)} size="sm" />
91 + <Stat label="No longer listed (7 d)" value={fmtInt(summary.removed_7d)} size="sm" />
92 + <Stat label="AI-related open" value={fmtInt(summary.ai_open)} size="sm" hint={summary.open ? `${Math.round((summary.ai_open / summary.open) * 100)} % of open` : undefined} />
93 + <Stat label="Remote share" value={summary.remote_ratio === null ? '—' : fmtPct(summary.remote_ratio, 0, true)} size="sm" />
94 + </StatGrid>
95 + ) : (
96 + <Note>Job summary unavailable.</Note>
97 + )}
98 + {summary && (summary.by_department.length > 0 || summary.by_country.length > 0) && (
99 + <div className="grid gap-6 md:grid-cols-2">
100 + <div>
101 + <p className="eyebrow mb-2">By department</p>
102 + <Bars dense rows={summary.by_department.slice(0, 8).map((d) => ({ key: d.department, label: d.department, value: d.n }))} />
103 + </div>
104 + <div>
105 + <p className="eyebrow mb-2">By country</p>
106 + <Bars dense rows={summary.by_country.slice(0, 8).map((d) => ({ key: d.country, label: countryName(d.country), value: d.n }))} />
107 + </div>
108 + </div>
109 + )}
110 + <div className="flex flex-wrap items-center gap-1.5">
111 + {[
112 + ['open', 'Open'],
113 + ['removed', 'No longer listed'],
114 + ['all', 'All'],
115 + ].map(([v, l]) => (
116 + <Link key={v} href={link({ status: v })} scroll={false} className="chip-btn" data-on={status === v}>
117 + {l}
118 + </Link>
119 + ))}
120 + <Link href={link({ ai: !ai })} scroll={false} className="chip-btn ml-2" data-on={ai} aria-pressed={ai}>
121 + AI-related only
122 + </Link>
123 + {data && <span className="tnum ml-auto text-xs text-ink-3">{fmtInt(data.total)} listings</span>}
124 + </div>
125 + {!data ? (
126 + <Empty title="Job listings temporarily unavailable." />
127 + ) : data.items.length === 0 ? (
128 + <Empty>No monitored listings match this filter.</Empty>
129 + ) : (
130 + <JobsTable items={data.items} />
131 + )}
132 + <Note>Counts reflect listings visible on the monitored careers surfaces only. A listing that is no longer visible is reported as “no longer listed” — it is not evidence of a hiring decision.</Note>
133 + </div>
134 + );
135 +}
136 +
137 +function JobsTable({ items }: { items: Job[] }) {
138 + return (
139 + <div className="table-scroll">
140 + <table className="data-table">
141 + <thead>
142 + <tr>
143 + <th>Title</th>
144 + <th>Department</th>
145 + <th>Location</th>
146 + <th>Type</th>
147 + <th>First seen</th>
148 + <th>Status</th>
149 + </tr>
150 + </thead>
151 + <tbody>
152 + {items.map((j) => (
153 + <tr key={j.id}>
154 + <td className="primary wrap">
155 + {j.url ? (
156 + <a href={j.url} target="_blank" rel="noopener noreferrer" className="row-link inline-flex items-center gap-1">
157 + {j.title} <ExternalLink className="size-3 text-ink-3" aria-hidden />
158 + </a>
159 + ) : (
160 + j.title
161 + )}
162 + {j.is_ai && (
163 + <Chip tone="accent" className="ml-1.5">
164 + AI
165 + </Chip>
166 + )}
167 + </td>
168 + <td className="text-ink-2">{j.department ?? '—'}</td>
169 + <td className="text-ink-2">
170 + {j.location_text ?? '—'}
171 + {j.remote && <span className="ml-1 text-[11px] text-ink-3">· remote</span>}
172 + </td>
173 + <td className="text-xs text-ink-3">
174 + {j.employment_type ? humanize(j.employment_type) : '—'}
175 + {j.seniority ? ` · ${j.seniority}` : ''}
176 + </td>
177 + <td className="tnum text-xs text-ink-3">{fmtDate(j.first_seen_at)}</td>
178 + <td>
179 + <StatusBadge status={j.status} />
180 + {j.removed_at && <span className="ml-1 text-[11px] text-ink-3">{fmtDate(j.removed_at)}</span>}
181 + </td>
182 + </tr>
183 + ))}
184 + </tbody>
185 + </table>
186 + </div>
187 + );
188 +}
189 +
190 +/* ------------------------------------------------------------------------------------------------------------ products */
191 +export function ProductsPanel({ data }: { data: { listed: Product[]; removed: Product[] } | null }) {
192 + if (!data) return <Empty title="Products temporarily unavailable." />;
193 + if (!data.listed.length && !data.removed.length) return <Empty>No product catalog surface has been reconciled for this company yet.</Empty>;
194 + const Row = ({ p }: { p: Product }) => (
195 + <li className="py-2.5">
196 + <div className="flex flex-wrap items-center gap-2">
197 + {p.url ? (
198 + <a href={p.url} target="_blank" rel="noopener noreferrer" className="text-[15px] font-medium text-ink hover:text-accent">
199 + {p.name}
200 + </a>
201 + ) : (
202 + <span className="text-[15px] font-medium text-ink">{p.name}</span>
203 + )}
204 + {p.category && <Chip>{p.category}</Chip>}
205 + <StatusBadge status={p.status} />
206 + <span className="ml-auto text-[11px] text-ink-3">
207 + first seen {fmtDate(p.first_seen_at)}
208 + {p.removed_at ? ` · no longer listed ${fmtDate(p.removed_at)}` : ` · last seen ${fmtDate(p.last_seen_at)}`}
209 + </span>
210 + </div>
211 + {p.description && <p className="mt-0.5 text-sm text-ink-2">{p.description}</p>}
212 + </li>
213 + );
214 + return (
215 + <div className="space-y-6">
216 + <div>
217 + <p className="eyebrow mb-1">
218 + Listed <span className="tnum normal-case tracking-normal">({data.listed.length})</span>
219 + </p>
220 + <ul className="divide-y divide-rule border-y border-rule">{data.listed.map((p) => <Row key={p.id} p={p} />)}</ul>
221 + </div>
222 + {data.removed.length > 0 && (
223 + <div>
224 + <p className="eyebrow mb-1">
225 + No longer listed <span className="tnum normal-case tracking-normal">({data.removed.length})</span>
226 + </p>
227 + <ul className="divide-y divide-rule border-y border-rule">{data.removed.map((p) => <Row key={p.id} p={p} />)}</ul>
228 + <Note className="mt-2">A product that disappears from the public catalog is recorded as “no longer listed”; the platform does not infer discontinuation without a first-party statement.</Note>
229 + </div>
230 + )}
231 + </div>
232 + );
233 +}
234 +
235 +/* ------------------------------------------------------------------------------------------------------------ pricing */
236 +export function PricingPanel({ data }: { data: { current: Plan[]; history: Plan[] } | null }) {
237 + if (!data) return <Empty title="Pricing temporarily unavailable." />;
238 + if (!data.current.length && !data.history.length) return <Empty>No public pricing page is monitored for this company yet.</Empty>;
239 + return (
240 + <div className="space-y-6">
241 + <div>
242 + <p className="eyebrow mb-2">Current plans</p>
243 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
244 + {data.current.map((p) => (
245 + <div key={p.id} className="border border-rule p-3">
246 + <p className="text-sm font-medium text-ink">{p.plan_name}</p>
247 + <p className="tnum mt-1 text-2xl font-semibold tracking-tight">{p.contact_sales ? <span className="text-base font-medium text-ink-2">Contact sales</span> : fmtPrice(p.price, p.currency, p.price_text)}</p>
248 + {!p.contact_sales && (
249 + <p className="text-xs text-ink-3">
250 + {p.billing_period ? `per ${p.billing_period}` : ''}
251 + {p.unit ? ` · per ${p.unit}` : ''}
252 + </p>
253 + )}
254 + {p.features.length > 0 && (
255 + <ul className="mt-2 space-y-0.5 text-xs text-ink-2">
256 + {p.features.slice(0, 6).map((f) => (
257 + <li key={f}>· {f}</li>
258 + ))}
259 + </ul>
260 + )}
261 + <p className="mt-2 text-[11px] text-ink-3">
262 + v{p.version_no} · since {fmtDate(p.valid_from)}
263 + </p>
264 + </div>
265 + ))}
266 + </div>
267 + </div>
268 + {data.history.length > 0 && (
269 + <div>
270 + <p className="eyebrow mb-2">Version history</p>
271 + <div className="table-scroll">
272 + <table className="data-table">
273 + <thead>
274 + <tr>
275 + <th>Plan</th>
276 + <th className="num">Version</th>
277 + <th className="num">Price</th>
278 + <th>Billing</th>
279 + <th>Valid from</th>
280 + <th>Valid to</th>
281 + <th>Source</th>
282 + </tr>
283 + </thead>
284 + <tbody>
285 + {[...data.history].sort((a, b) => b.valid_from.localeCompare(a.valid_from)).map((p) => (
286 + <tr key={p.id}>
287 + <td className="primary">{p.plan_name}</td>
288 + <td className="num tnum">v{p.version_no}</td>
289 + <td className="num tnum">{p.contact_sales ? 'Contact sales' : fmtPrice(p.price, p.currency, p.price_text)}</td>
290 + <td className="text-ink-2">{p.billing_period ?? '—'}</td>
291 + <td className="tnum text-xs">{fmtDate(p.valid_from)}</td>
292 + <td className="tnum text-xs">{p.valid_to ? fmtDate(p.valid_to) : '—'}</td>
293 + <td>
294 + {p.source_url ? (
295 + <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="link text-xs">
296 + {pathOf(p.source_url)}
297 + </a>
298 + ) : (
299 + '—'
300 + )}
301 + </td>
302 + </tr>
303 + ))}
304 + </tbody>
305 + </table>
306 + </div>
307 + <Note className="mt-2">Every version of every plan is preserved; a price change creates a new version rather than overwriting the previous one.</Note>
308 + </div>
309 + )}
310 + </div>
311 + );
312 +}
313 +
314 +/* ------------------------------------------------------------------------------------------------------------ locations */
315 +export function LocationsPanel({ data, map }: { data: { items: Location[]; countries: string[] } | null; map?: React.ReactNode }) {
316 + if (!data) return <Empty title="Locations temporarily unavailable." />;
317 + if (!data.items.length) return <Empty>No locations surface has been reconciled for this company yet.</Empty>;
318 + const listed = data.items.filter((l) => l.status === 'listed');
319 + const gone = data.items.filter((l) => l.status !== 'listed');
320 + return (
321 + <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
322 + <div>
323 + <p className="eyebrow mb-1">
324 + Listed <span className="tnum normal-case tracking-normal">({listed.length} in {data.countries.length} {plural(data.countries.length, 'country', 'countries')})</span>
325 + </p>
326 + <ul className="divide-y divide-rule border-y border-rule text-sm">
327 + {listed.map((l) => (
328 + <li key={l.id} className="flex flex-wrap items-center gap-2 py-2">
329 + <Chip>{l.kind}</Chip>
330 + <span className="text-ink">{[l.city, l.region].filter(Boolean).join(', ') || l.name}</span>
331 + <CountryChip code={l.country} name={countryName(l.country)} />
332 + <span className="ml-auto text-[11px] text-ink-3">since {fmtDate(l.first_seen_at)}</span>
333 + </li>
334 + ))}
335 + </ul>
336 + {gone.length > 0 && (
337 + <>
338 + <p className="eyebrow mb-1 mt-4">No longer listed ({gone.length})</p>
339 + <ul className="divide-y divide-rule border-y border-rule text-sm text-ink-2">
340 + {gone.map((l) => (
341 + <li key={l.id} className="flex flex-wrap items-center gap-2 py-2">
342 + <Chip>{l.kind}</Chip>
343 + <span>{[l.city, l.region].filter(Boolean).join(', ') || l.name}</span>
344 + <CountryChip code={l.country} />
345 + <span className="ml-auto text-[11px] text-ink-3">until {fmtDate(l.removed_at)}</span>
346 + </li>
347 + ))}
348 + </ul>
349 + </>
350 + )}
351 + <Note className="mt-2">Only cities/regions/countries are recorded; exact street addresses are never inferred.</Note>
352 + </div>
353 + <div className="min-w-0">{map}</div>
354 + </div>
355 + );
356 +}
357 +
358 +/* ------------------------------------------------------------------------------------------------------------ leadership */
359 +export function PeoplePanel({ data }: { data: { listed: Person[]; no_longer_listed: Person[] } | null }) {
360 + if (!data) return <Empty title="Leadership temporarily unavailable." />;
361 + if (!data.listed.length && !data.no_longer_listed.length) return <Empty>No leadership page is monitored for this company yet.</Empty>;
362 + const Row = ({ p }: { p: Person }) => (
363 + <li className="flex flex-wrap items-center gap-2 py-2 text-sm">
364 + <span className="font-medium text-ink">{p.name}</span>
365 + <span className="text-ink-2">{p.title ?? '—'}</span>
366 + {p.is_executive && <Chip>executive</Chip>}
367 + <span className="ml-auto text-[11px] text-ink-3">
368 + {p.status === 'listed' ? `listed since ${fmtDate(p.first_seen_at)}` : `no longer listed since ${fmtDate(p.removed_at ?? p.last_seen_at)}`}
369 + {p.source_url && (
370 + <>
371 + {' · '}
372 + <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">
373 + source ↗
374 + </a>
375 + </>
376 + )}
377 + </span>
378 + </li>
379 + );
380 + return (
381 + <div className="space-y-6">
382 + <div>
383 + <p className="eyebrow mb-1">Listed on the monitored leadership page ({data.listed.length})</p>
384 + <ul className="divide-y divide-rule border-y border-rule">{data.listed.map((p) => <Row key={p.id} p={p} />)}</ul>
385 + </div>
386 + {data.no_longer_listed.length > 0 && (
387 + <div>
388 + <p className="eyebrow mb-1">No longer listed ({data.no_longer_listed.length})</p>
389 + <ul className="divide-y divide-rule border-y border-rule">{data.no_longer_listed.map((p) => <Row key={p.id} p={p} />)}</ul>
390 + </div>
391 + )}
392 + <Note>Leadership data is limited to public professional context on the company’s own pages. A profile that disappears is “no longer listed on the monitored leadership page” — the platform never states why.</Note>
393 + </div>
394 + );
395 +}
396 +
397 +/* ------------------------------------------------------------------------------------------------------------ sensors */
398 +export function SensorsTable({ items, className, withCompany = false }: { items: (Sensor & { company?: { slug: string; display_name: string } })[]; className?: string; withCompany?: boolean }) {
399 + if (!items.length) return <Empty>No sensors attached yet.</Empty>;
400 + return (
401 + <div className={cn('table-scroll', className)}>
402 + <table className="data-table">
403 + <thead>
404 + <tr>
405 + {withCompany && <th>Company</th>}
406 + <th>Surface</th>
407 + <th>URL</th>
408 + <th>Connector</th>
409 + <th>Status</th>
410 + <th>Tier</th>
411 + <th className="num">Quality</th>
412 + <th>Last checked</th>
413 + <th className="num">Obs.</th>
414 + <th className="num">Changes</th>
415 + <th className="num">Events</th>
416 + </tr>
417 + </thead>
418 + <tbody>
419 + {items.map((s) => {
420 + const stale = s.last_success_at && Date.now() - new Date(s.last_success_at).getTime() > 2 * 86_400_000;
421 + return (
422 + <tr key={s.id}>
423 + {withCompany && <td className="primary">{s.company ? <Link href={routes.company(s.company.slug)} className="row-link">{s.company.display_name}</Link> : '—'}</td>}
424 + <td className="primary">
425 + <Link href={routes.sensor(s.id)} className="row-link">
426 + {SURFACE_LABELS[s.surface] ?? s.surface}
427 + </Link>
428 + </td>
429 + <td className="mono text-xs text-ink-2">
430 + <a href={s.url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">
431 + {pathOf(s.url)}
432 + </a>
433 + </td>
434 + <td className="mono text-xs text-ink-3">{s.connector_id}</td>
435 + <td>
436 + <StatusBadge status={s.status} />
437 + {s.last_failure_class && <span className="mono ml-1 text-[10px] text-danger">{s.last_failure_class}</span>}
438 + </td>
439 + <td>
440 + <SensorTierBadge tier={s.tier} />
441 + </td>
442 + <td className="num tnum">{fmtScore(s.quality_score)}</td>
443 + <td className={cn('text-xs', stale ? 'text-warning' : 'text-ink-3')}>
444 + <LiveAgo at={s.last_success_at ?? s.last_run_at} tick={30000} absoluteFallback={false} prefix={stale ? 'last success ' : ''} />
445 + </td>
446 + <td className="num tnum">{fmtInt(s.observation_count)}</td>
447 + <td className="num tnum">{fmtInt(s.change_count)}</td>
448 + <td className="num tnum">{fmtInt(s.event_count)}</td>
449 + </tr>
450 + );
451 + })}
452 + </tbody>
453 + </table>
454 + </div>
455 + );
456 +}
457 +
458 +/* ------------------------------------------------------------------------------------------------------------ history viewer */
459 +export function HistoryPanel({ data }: { data: HistoryPayload | null }) {
460 + if (!data) return <Empty title="Historical page viewer temporarily unavailable." />;
461 + const sensors = data.sensors.filter((s) => s.versions.length > 0);
462 + if (!sensors.length) return <Empty>No snapshot versions stored yet.</Empty>;
463 + return (
464 + <div className="space-y-5">
465 + <Note>Every monitored page keeps its normalised versions. Open a version to read it as observed, or diff any two versions block by block.</Note>
466 + {sensors.map((s) => (
467 + <details key={s.id} className="border-y border-rule py-2" open={sensors.length <= 3}>
468 + <summary className="flex cursor-pointer flex-wrap items-center gap-2 py-1 text-sm">
469 + <span className="font-medium text-ink">{SURFACE_LABELS[s.surface] ?? s.surface}</span>
470 + <span className="mono text-xs text-ink-3">{pathOf(s.url)}</span>
471 + <SensorTierBadge tier={s.tier} />
472 + <span className="tnum ml-auto text-xs text-ink-3">
473 + {s.versions.length} {plural(s.versions.length, 'version')} · every {fmtDuration(s.current_interval_s)}
474 + </span>
475 + </summary>
476 + <div className="table-scroll mt-1">
477 + <table className="data-table compact">
478 + <thead>
479 + <tr>
480 + <th className="num">v</th>
481 + <th>Fetched</th>
482 + <th>Title</th>
483 + <th className="num">Blocks</th>
484 + <th className="num">Text</th>
485 + <th>Hash</th>
486 + <th>Diff</th>
487 + </tr>
488 + </thead>
489 + <tbody>
490 + {s.versions.map((v, i) => {
491 + const prev = s.versions[i + 1];
492 + return (
493 + <tr key={v.id}>
494 + <td className="num tnum">{v.version_no}</td>
495 + <td className="tnum text-xs">
496 + <Link href={routes.snapshot(v.id)} className="link">
497 + {fmtDateTime(v.fetched_at)}
498 + </Link>
499 + </td>
500 + <td className="wrap text-ink-2">{v.title ?? '—'}</td>
501 + <td className="num tnum">{fmtInt(v.block_count)}</td>
502 + <td className="num tnum">{fmtInt(v.text_length)}</td>
503 + <td className="mono text-[11px] text-ink-3">{v.content_hash.replace('sha256:', '').slice(0, 10)}</td>
504 + <td className="text-xs">
505 + {prev ? (
506 + <Link href={routes.snapshotDiff(prev.id, v.id)} className="link">
507 + vs v{prev.version_no}
508 + </Link>
509 + ) : (
510 + <span className="text-ink-3">first version</span>
511 + )}
512 + </td>
513 + </tr>
514 + );
515 + })}
516 + </tbody>
517 + </table>
518 + </div>
519 + </details>
520 + ))}
521 + </div>
522 + );
523 +}
524 +
525 +export function ConfidenceLegend() {
526 + return (
527 + <p className="flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3">
528 + Confidence labels: {['VERIFIED', 'HIGH_CONFIDENCE', 'LIKELY', 'INFERRED', 'LOW_CONFIDENCE'].map((l) => <ConfidenceBadge key={l} label={l} />)}
529 + </p>
530 + );
531 +}
added apps/web/src/components/company/watch-button.tsx +44 −0
@@ -0,0 +1,44 @@
1 +'use client';
2 +import { Bookmark, BookmarkCheck } from 'lucide-react';
3 +import { useState } from 'react';
4 +import { ownerApi } from '@/lib/client-api';
5 +import { cn } from '@/lib/cn';
6 +import { ensureOwnerToken, readWatched, useWatched, writeWatched } from '@/lib/owner';
7 +
8 +/** Watch / unwatch a company (owner token generated on first use). Optimistic; mirrors the slug locally. */
9 +export function WatchButton({ slug, name, className, size = 'md' }: { slug: string; name?: string; className?: string; size?: 'sm' | 'md' }) {
10 + const watched = useWatched();
11 + const on = watched.includes(slug);
12 + const [busy, setBusy] = useState(false);
13 + const [err, setErr] = useState<string | null>(null);
14 + const toggle = async () => {
15 + setBusy(true);
16 + setErr(null);
17 + const token = ensureOwnerToken();
18 + const api = ownerApi(token);
19 + const cur = readWatched();
20 + try {
21 + if (on) {
22 + writeWatched(cur.filter((s) => s !== slug));
23 + await api.unwatch(slug);
24 + } else {
25 + writeWatched([...cur, slug]);
26 + await api.watch(slug);
27 + }
28 + } catch (e) {
29 + writeWatched(cur);
30 + setErr((e as Error).message || 'Could not update watchlist');
31 + } finally {
32 + setBusy(false);
33 + }
34 + };
35 + return (
36 + <span className={cn('inline-flex flex-col items-start', className)}>
37 + <button type="button" onClick={toggle} disabled={busy} aria-pressed={on} className={cn('btn', size === 'sm' && 'btn-sm', on && 'border-accent bg-accent-soft text-accent')} data-watch={slug} title={on ? `Stop watching ${name ?? slug}` : `Watch ${name ?? slug}`}>
38 + {on ? <BookmarkCheck className="size-4" aria-hidden /> : <Bookmark className="size-4" aria-hidden />}
39 + {on ? 'Watching' : 'Watch'}
40 + </button>
41 + {err && <span className="mt-1 text-[11px] text-danger">{err}</span>}
42 + </span>
43 + );
44 +}
added apps/web/src/components/company/watchlist-client.tsx +235 −0
@@ -0,0 +1,235 @@
1 +'use client';
2 +import { Bell, Trash2 } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useCallback, useEffect, useState } from 'react';
5 +import { EventList } from '@/components/events/event-row';
6 +import { EventTypeBadge } from '@/components/ui/badges';
7 +import { LiveAgo } from '@/components/ui/live';
8 +import { Empty, Note, Section } from '@/components/ui/section';
9 +import { SkeletonRows } from '@/components/ui/skeleton';
10 +import { ownerApi } from '@/lib/client-api';
11 +import { EVENT_TYPES } from '@/lib/event-styles';
12 +import { readWatched, setOwnerToken, useOwnerToken, writeWatched } from '@/lib/owner';
13 +import { routes } from '@/lib/site';
14 +import type { Alert, AlertDelivery, WatchlistPayload } from '@/lib/types';
15 +import { CompanyTable } from './company-table';
16 +
17 +/** /watchlist: watched companies with their latest events, alert rules and recent deliveries — all keyed by the owner token. */
18 +export function WatchlistClient() {
19 + const token = useOwnerToken(true);
20 + const [data, setData] = useState<WatchlistPayload | null>(null);
21 + const [alerts, setAlerts] = useState<Alert[]>([]);
22 + const [deliveries, setDeliveries] = useState<AlertDelivery[]>([]);
23 + const [error, setError] = useState<string | null>(null);
24 + const [loading, setLoading] = useState(true);
25 + const [showToken, setShowToken] = useState(false);
26 + const [importDraft, setImportDraft] = useState('');
27 + const [form, setForm] = useState<{ name: string; company: string; event_types: string[]; min_importance: string; channel: 'web' | 'webhook'; target: string }>({ name: '', company: '', event_types: [], min_importance: '', channel: 'web', target: '' });
28 +
29 + const load = useCallback(async () => {
30 + if (!token) return;
31 + const api = ownerApi(token);
32 + setLoading(true);
33 + try {
34 + const [w, a, d] = await Promise.all([api.watchlist(), api.alerts().catch(() => ({ items: [] })), api.deliveries(30).catch(() => ({ items: [] }))]);
35 + setData(w);
36 + setAlerts(a.items ?? []);
37 + setDeliveries(d.items ?? []);
38 + setError(null);
39 + // reconcile the local mirror with the server truth
40 + writeWatched(w.items.map((c) => c.slug));
41 + } catch (e) {
42 + setError((e as Error).message);
43 + // fall back to the local mirror for names only
44 + if (!data) setData({ items: [], events: [] });
45 + } finally {
46 + setLoading(false);
47 + }
48 + // eslint-disable-next-line react-hooks/exhaustive-deps
49 + }, [token]);
50 + useEffect(() => {
51 + load();
52 + }, [load]);
53 +
54 + const remove = async (slug: string) => {
55 + if (!token) return;
56 + writeWatched(readWatched().filter((s) => s !== slug));
57 + setData((d) => (d ? { ...d, items: d.items.filter((c) => c.slug !== slug) } : d));
58 + try {
59 + await ownerApi(token).unwatch(slug);
60 + } catch {
61 + /* reload will reconcile */
62 + }
63 + };
64 + const createAlert = async (e: React.FormEvent) => {
65 + e.preventDefault();
66 + if (!token || !form.name.trim()) return;
67 + try {
68 + const a = await ownerApi(token).createAlert({ name: form.name.trim(), company: form.company || undefined, condition: { event_types: form.event_types.length ? form.event_types : undefined, min_importance: form.min_importance ? Number(form.min_importance) : undefined }, channel: form.channel, target: form.channel === 'webhook' ? form.target : undefined });
69 + setAlerts((l) => [...l, a]);
70 + setForm({ name: '', company: '', event_types: [], min_importance: '', channel: 'web', target: '' });
71 + } catch (err) {
72 + setError((err as Error).message);
73 + }
74 + };
75 + const deleteAlert = async (id: string) => {
76 + if (!token) return;
77 + setAlerts((l) => l.filter((a) => a.id !== id));
78 + try {
79 + await ownerApi(token).deleteAlert(id);
80 + } catch {
81 + /* ignore */
82 + }
83 + };
84 +
85 + const localOnly = readWatched();
86 + return (
87 + <div data-watchlist>
88 + {error && (
89 + <p className="mb-4 border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-warning" role="status">
90 + Watchlist service: {error}. Your locally saved list ({localOnly.length}) is kept and will sync when the API answers.
91 + </p>
92 + )}
93 + <Section eyebrow="Watched companies" title={data ? `${data.items.length} ${data.items.length === 1 ? 'company' : 'companies'}` : 'Loading…'} hairline={false} action={{ href: routes.companies(), label: 'Find companies' }}>
94 + {loading && !data ? (
95 + <SkeletonRows />
96 + ) : data && data.items.length ? (
97 + <>
98 + <CompanyTable items={data.items} />
99 + <ul className="mt-2 flex flex-wrap gap-1.5">
100 + {data.items.map((c) => (
101 + <li key={c.slug}>
102 + <button type="button" onClick={() => remove(c.slug)} className="chip-btn" aria-label={`Stop watching ${c.display_name}`}>
103 + <Trash2 className="size-3" aria-hidden /> {c.display_name}
104 + </button>
105 + </li>
106 + ))}
107 + </ul>
108 + {data.items.length >= 2 && (
109 + <p className="mt-3 text-sm">
110 + <Link href={routes.compare(data.items.slice(0, 6).map((c) => c.slug))} className="link">
111 + Compare watched companies →
112 + </Link>
113 + </p>
114 + )}
115 + </>
116 + ) : (
117 + <Empty title="Your watchlist is empty.">
118 + Use the <span className="font-medium text-ink-2">Watch</span> button on any company page. No account is needed — the list is tied to a token stored in this browser.
119 + </Empty>
120 + )}
121 + </Section>
122 +
123 + <Section eyebrow="Latest events" title="Across watched companies">
124 + {data ? <EventList events={data.events} variant="table" emptyLabel="No events yet for the companies you watch." /> : <SkeletonRows />}
125 + </Section>
126 +
127 + <Section eyebrow="Alerts" title="Rules" lede="Get notified in the web feed or by webhook when watched companies produce matching events. Rules are evaluated on the server against new structured events.">
128 + <form onSubmit={createAlert} className="grid gap-2 border border-rule p-3 md:grid-cols-[1.5fr_1fr_1fr_1fr_auto]" data-alert-form>
129 + <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name (required)" className="field" aria-label="Rule name" required />
130 + <select value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className="field" aria-label="Company">
131 + <option value="">Any watched company</option>
132 + {(data?.items ?? []).map((c) => (
133 + <option key={c.slug} value={c.slug}>
134 + {c.display_name}
135 + </option>
136 + ))}
137 + </select>
138 + <select value={form.min_importance} onChange={(e) => setForm({ ...form, min_importance: e.target.value })} className="field" aria-label="Minimum importance">
139 + <option value="">Any importance</option>
140 + <option value="0.5">Importance ≥ 50</option>
141 + <option value="0.7">Importance ≥ 70</option>
142 + <option value="0.85">Importance ≥ 85</option>
143 + </select>
144 + <select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as 'web' | 'webhook' })} className="field" aria-label="Channel">
145 + <option value="web">Web</option>
146 + <option value="webhook">Webhook</option>
147 + </select>
148 + <button type="submit" className="btn btn-primary">
149 + <Bell className="size-4" aria-hidden /> Add rule
150 + </button>
151 + {form.channel === 'webhook' && <input value={form.target} onChange={(e) => setForm({ ...form, target: e.target.value })} placeholder="https://your-endpoint.example/hook" className="field md:col-span-5" aria-label="Webhook URL" type="url" required />}
152 + <div className="flex flex-wrap gap-1 md:col-span-5">
153 + {EVENT_TYPES.map((t) => {
154 + const on = form.event_types.includes(t);
155 + return (
156 + <button key={t} type="button" onClick={() => setForm({ ...form, event_types: on ? form.event_types.filter((x) => x !== t) : [...form.event_types, t] })} className="chip-btn" data-on={on} aria-pressed={on}>
157 + {t}
158 + </button>
159 + );
160 + })}
161 + <span className="self-center text-[11px] text-ink-3">no selection = all types</span>
162 + </div>
163 + </form>
164 + {alerts.length > 0 ? (
165 + <ul className="mt-4 divide-y divide-rule border-y border-rule text-sm">
166 + {alerts.map((a) => (
167 + <li key={a.id} className="flex flex-wrap items-center gap-2 py-2">
168 + <span className="font-medium text-ink">{a.name}</span>
169 + <span className="text-xs text-ink-3">{a.company ? `company ${a.company}` : 'any watched company'}</span>
170 + {(a.condition.event_types ?? []).map((t) => (
171 + <EventTypeBadge key={t} type={t} small />
172 + ))}
173 + {a.condition.min_importance !== undefined && <span className="tnum text-xs text-ink-3">importance ≥ {Math.round(a.condition.min_importance * 100)}</span>}
174 + <span className="mono text-[11px] text-ink-3">{a.channel}</span>
175 + <button type="button" onClick={() => deleteAlert(a.id)} className="ml-auto flex size-9 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Delete rule ${a.name}`}>
176 + <Trash2 className="size-4" aria-hidden />
177 + </button>
178 + </li>
179 + ))}
180 + </ul>
181 + ) : (
182 + <Note className="mt-3">No rules yet.</Note>
183 + )}
184 + {deliveries.length > 0 && (
185 + <div className="mt-6">
186 + <p className="eyebrow mb-1">Recent deliveries</p>
187 + <ul className="divide-y divide-rule border-y border-rule text-sm">
188 + {deliveries.map((d) => (
189 + <li key={d.id} className="flex flex-wrap items-center gap-2 py-2">
190 + <span className="text-xs text-ink-3">{d.alert_name ?? d.alert_id}</span>
191 + {d.event ? (
192 + <Link href={routes.event(d.event.id)} className="min-w-0 flex-1 truncate hover:text-accent">
193 + {d.event.title}
194 + </Link>
195 + ) : (
196 + <span className="mono text-xs">{d.event_id}</span>
197 + )}
198 + <span className="mono text-[11px] text-ink-3">
199 + {d.channel} · {d.status}
200 + </span>
201 + <LiveAgo at={d.delivered_at} tick={30000} className="text-xs text-ink-3" />
202 + </li>
203 + ))}
204 + </ul>
205 + </div>
206 + )}
207 + </Section>
208 +
209 + <Section eyebrow="Your token" title="No account, one token">
210 + <p className="text-sm text-ink-2">Watchlists and alerts belong to a random token generated by this browser and stored in localStorage; the server keeps only a hash. Clearing site data loses the list — copy the token to move it to another device.</p>
211 + <div className="mt-3 flex flex-wrap items-center gap-2">
212 + <button type="button" onClick={() => setShowToken((v) => !v)} className="btn btn-sm">
213 + {showToken ? 'Hide token' : 'Show token'}
214 + </button>
215 + {showToken && token && <code className="mono break-all border border-rule bg-surface-2 px-2 py-1 text-xs">{token}</code>}
216 + </div>
217 + <form
218 + className="mt-3 flex max-w-lg gap-2"
219 + onSubmit={(e) => {
220 + e.preventDefault();
221 + if (setOwnerToken(importDraft)) {
222 + setImportDraft('');
223 + load();
224 + }
225 + }}
226 + >
227 + <input value={importDraft} onChange={(e) => setImportDraft(e.target.value)} placeholder="Paste a token from another device" className="field flex-1 text-xs" aria-label="Import token" />
228 + <button type="submit" className="btn btn-sm" disabled={importDraft.trim().length < 24}>
229 + Import
230 + </button>
231 + </form>
232 + </Section>
233 + </div>
234 + );
235 +}
added apps/web/src/components/events/diff-viewer.tsx +85 −0
@@ -0,0 +1,85 @@
1 +import { SignificanceBadge } from '@/components/ui/badges';
2 +import { Note } from '@/components/ui/section';
3 +import { cn } from '@/lib/cn';
4 +import { fmtPct } from '@/lib/format';
5 +import type { BlockDelta, DiffPayload } from '@/lib/types';
6 +
7 +/** Block-level diff (added / removed / modified with before → after text), significance meter and reasons. */
8 +export function DiffViewer({ diff, significance, className }: { diff: DiffPayload; significance?: number | null; className?: string }) {
9 + const total = diff.added.length + diff.removed.length + diff.modified.length;
10 + return (
11 + <div className={cn('space-y-5', className)} data-diff-viewer>
12 + <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
13 + <div>
14 + <p className="eyebrow">Significance</p>
15 + <div className="mt-1 flex items-center gap-3">
16 + <div className="meter flex-1">
17 + <span style={{ width: `${Math.round((significance ?? 0) * 100)}%`, background: (significance ?? 0) >= 0.65 ? 'var(--warning)' : (significance ?? 0) >= 0.4 ? 'var(--accent)' : 'var(--ink-3)' }} />
18 + </div>
19 + <SignificanceBadge value={significance} />
20 + </div>
21 + <p className="tnum mt-2 text-xs text-ink-3">
22 + text delta {fmtPct(diff.text_delta_ratio, 1, true)} · similarity {fmtPct(diff.similarity, 1, true)} · {diff.counts.unchanged !== undefined ? `${diff.counts.unchanged} unchanged blocks` : `${total} changed blocks`}
23 + </p>
24 + <div className="mt-2 flex flex-wrap gap-3 text-xs">
25 + <span className="text-positive">+{diff.added.length} added</span>
26 + <span className="text-danger">−{diff.removed.length} removed</span>
27 + <span className="text-warning">~{diff.modified.length} modified</span>
28 + {diff.moved.length > 0 && <span className="text-ink-3">↕ {diff.moved.length} moved</span>}
29 + </div>
30 + </div>
31 + <div>
32 + <p className="eyebrow">Why it scored this way</p>
33 + {diff.reasons.length ? (
34 + <ul className="mt-1 space-y-0.5 text-sm text-ink-2">
35 + {diff.reasons.map((r) => (
36 + <li key={r}>· {r}</li>
37 + ))}
38 + </ul>
39 + ) : (
40 + <Note className="mt-1">No reasons recorded for this diff.</Note>
41 + )}
42 + </div>
43 + </div>
44 +
45 + {total === 0 ? (
46 + <Note>No block-level differences above the noise floor between these versions.</Note>
47 + ) : (
48 + <div className="space-y-4">
49 + {diff.added.length > 0 && <Group label="Added" items={diff.added} kind="added" />}
50 + {diff.removed.length > 0 && <Group label="Removed" items={diff.removed} kind="removed" />}
51 + {diff.modified.length > 0 && <Group label="Modified" items={diff.modified} kind="modified" />}
52 + </div>
53 + )}
54 + </div>
55 + );
56 +}
57 +
58 +function Group({ label, items, kind }: { label: string; items: BlockDelta[]; kind: 'added' | 'removed' | 'modified' }) {
59 + return (
60 + <section>
61 + <p className="eyebrow mb-1.5">
62 + {label} <span className="tnum normal-case tracking-normal text-ink-3">({items.length})</span>
63 + </p>
64 + <ul className="space-y-2">
65 + {items.map((b) => (
66 + <li key={`${kind}-${b.key}`} className={cn('diff-block', kind)}>
67 + <p className="mono mb-1 flex flex-wrap items-center gap-2 text-[10px] text-ink-3">
68 + <span className="uppercase tracking-wider">{b.kind}</span>
69 + <span className="truncate">{b.path}</span>
70 + <span className="ml-auto tnum">w {b.weight.toFixed(2)}{b.similarity !== null ? ` · sim ${b.similarity.toFixed(2)}` : ''}</span>
71 + </p>
72 + {kind === 'modified' ? (
73 + <>
74 + <p className="diff-before">{b.before}</p>
75 + <p className="diff-after mt-1">{b.after}</p>
76 + </>
77 + ) : (
78 + <p className={kind === 'removed' ? 'diff-before' : 'diff-after'}>{kind === 'removed' ? b.before : b.after}</p>
79 + )}
80 + </li>
81 + ))}
82 + </ul>
83 + </section>
84 + );
85 +}
added apps/web/src/components/events/event-drawer-context.tsx +23 −0
@@ -0,0 +1,23 @@
1 +'use client';
2 +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
3 +import type { Event } from '@/lib/types';
4 +
5 +interface Ctx {
6 + event: Event | null;
7 + open: (e: Event) => void;
8 + close: () => void;
9 +}
10 +const EventDrawerCtx = createContext<Ctx>({ event: null, open: () => undefined, close: () => undefined });
11 +
12 +/** Mounted once in the root layout: any event row can open the evidence drawer with the event it already has. */
13 +export function EventDrawerProvider({ children }: { children: ReactNode }) {
14 + const [event, setEvent] = useState<Event | null>(null);
15 + const open = useCallback((e: Event) => setEvent(e), []);
16 + const close = useCallback(() => setEvent(null), []);
17 + const value = useMemo(() => ({ event, open, close }), [event, open, close]);
18 + return <EventDrawerCtx.Provider value={value}>{children}</EventDrawerCtx.Provider>;
19 +}
20 +
21 +export function useEventDrawer() {
22 + return useContext(EventDrawerCtx);
23 +}
added apps/web/src/components/events/event-drawer.tsx +74 −0
@@ -0,0 +1,74 @@
1 +'use client';
2 +import { ArrowUpRight } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useEffect, useState } from 'react';
5 +import { CountryChip, EventTypeBadge, ImportanceMeter } from '@/components/ui/badges';
6 +import { LiveAgo } from '@/components/ui/live';
7 +import { Sheet } from '@/components/ui/sheet';
8 +import { clientApi } from '@/lib/client-api';
9 +import { routes } from '@/lib/site';
10 +import type { EventDetail, EventSource } from '@/lib/types';
11 +import { useEventDrawer } from './event-drawer-context';
12 +import { EventEvidence } from './event-evidence';
13 +
14 +/** Right-side (≥ lg) / bottom (< lg) drawer: the event, its evidence and links to the permanent pages. */
15 +export function EventDrawer() {
16 + const { event, close } = useEventDrawer();
17 + const [detail, setDetail] = useState<{ id: string; sources: EventSource[]; loaded: boolean } | null>(null);
18 +
19 + useEffect(() => {
20 + if (!event) return;
21 + const ctrl = new AbortController();
22 + setDetail({ id: event.id, sources: event.sources ?? [], loaded: false });
23 + clientApi
24 + .event(event.id, ctrl.signal)
25 + .then((d: EventDetail) => setDetail({ id: event.id, sources: d.sources ?? [], loaded: true }))
26 + .catch(() => setDetail((s) => (s ? { ...s, loaded: true } : s)));
27 + return () => ctrl.abort();
28 + }, [event]);
29 +
30 + return (
31 + <Sheet
32 + open={event !== null}
33 + onClose={close}
34 + eyebrow={event ? <EventTypeBadge type={event.event_type} subtype={event.event_subtype} /> : undefined}
35 + title={event?.title}
36 + footer={
37 + event ? (
38 + <div className="flex flex-wrap items-center gap-2 text-sm">
39 + <Link href={routes.event(event.id)} onClick={close} className="btn btn-primary btn-sm">
40 + Open event page <ArrowUpRight className="size-3.5" aria-hidden />
41 + </Link>
42 + <Link href={routes.company(event.company.slug)} onClick={close} className="btn btn-sm">
43 + {event.company.display_name}
44 + </Link>
45 + {event.source_url && (
46 + <a href={event.source_url} target="_blank" rel="noopener noreferrer" className="btn btn-sm">
47 + Source ↗
48 + </a>
49 + )}
50 + </div>
51 + ) : undefined
52 + }
53 + >
54 + {event && (
55 + <div className="space-y-4" data-event-drawer>
56 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
57 + <Link href={routes.company(event.company.slug)} onClick={close} className="font-medium text-ink hover:text-accent">
58 + {event.company.display_name}
59 + </Link>
60 + <span className="mono text-xs text-ink-3">{event.company.canonical_domain}</span>
61 + <CountryChip code={event.company.country} />
62 + <span className="ml-auto inline-flex items-center gap-2 text-xs text-ink-3">
63 + <ImportanceMeter importance={event.importance} />
64 + <LiveAgo at={event.detected_at} />
65 + </span>
66 + </div>
67 + {event.summary && <p className="text-[14px] leading-relaxed text-ink-2">{event.summary}</p>}
68 + <EventEvidence event={event} sources={detail?.id === event.id ? detail.sources : event.sources} compact />
69 + {detail && detail.id === event.id && !detail.loaded && <p className="text-[11px] text-ink-3">Loading full source list…</p>}
70 + </div>
71 + )}
72 + </Sheet>
73 + );
74 +}
added apps/web/src/components/events/event-evidence.tsx +133 −0
@@ -0,0 +1,133 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import Link from 'next/link';
3 +import { ConfidenceBadge, OriginBadge, StatusBadge } from '@/components/ui/badges';
4 +import { KV, Row } from '@/components/ui/key-value';
5 +import { fmtDateTime, pathOf } from '@/lib/format';
6 +import { routes, SURFACE_LABELS } from '@/lib/site';
7 +import type { Event, EventSource } from '@/lib/types';
8 +
9 +/**
10 + * Evidence block shared by the drawer and `/events/[id]`: the careful-language contract (spec §165–171).
11 + * Every source is listed with its detection time; old/new values are shown verbatim; LLM origin shows model + prompt.
12 + */
13 +export function EventEvidence({ event, sources, compact = false }: { event: Event; sources: EventSource[] | undefined; compact?: boolean }) {
14 + const list = sources && sources.length ? sources : event.source_url ? [{ source_url: event.source_url, surface: event.surface, detected_at: event.detected_at, kind: 'primary', sensor_id: event.sensor_id }] : [];
15 + const llm = event.origin === 'llm' || event.origin === 'hybrid';
16 + return (
17 + <div className="space-y-4 text-sm">
18 + {event.status !== 'active' && (
19 + <p className="border-l-2 border-danger pl-3 text-xs text-danger" role="status">
20 + {event.status === 'retracted' ? 'This event was retracted after review. It is kept for auditability and is not counted in metrics.' : event.status === 'duplicate' ? 'This event was merged into another canonical event.' : 'This event is awaiting human review.'}
21 + </p>
22 + )}
23 + {(event.old_value || event.new_value) && (
24 + <div className="grid gap-2 sm:grid-cols-2">
25 + <div className="min-w-0 border border-rule p-2.5">
26 + <p className="eyebrow">Before</p>
27 + <p className="mt-1 break-words text-[13px] text-ink-2">{event.old_value ?? <span className="text-ink-3">not previously observed</span>}</p>
28 + </div>
29 + <div className="min-w-0 border border-rule p-2.5">
30 + <p className="eyebrow">After</p>
31 + <p className="mt-1 break-words text-[13px] text-ink">{event.new_value ?? <span className="text-ink-3">no longer observed</span>}</p>
32 + </div>
33 + </div>
34 + )}
35 + <KV className={compact ? '[&>div]:grid-cols-[6.5rem_minmax(0,1fr)]' : undefined}>
36 + <Row k="Confidence">
37 + <ConfidenceBadge label={event.confidence_label} value={event.confidence} withValue />
38 + </Row>
39 + <Row k="Importance">
40 + <span className="tnum">{Math.round(event.importance > 1 ? event.importance : event.importance * 100)} / 100</span>
41 + </Row>
42 + <Row k="Detected">
43 + <span className="tnum">{fmtDateTime(event.detected_at)}</span>
44 + </Row>
45 + {event.effective_at && (
46 + <Row k="Effective">
47 + <span className="tnum">{fmtDateTime(event.effective_at)}</span>
48 + </Row>
49 + )}
50 + {event.published_at && (
51 + <Row k="Published">
52 + <span className="tnum">{fmtDateTime(event.published_at)}</span>
53 + </Row>
54 + )}
55 + {event.surface && <Row k="Surface">{SURFACE_LABELS[event.surface] ?? event.surface}</Row>}
56 + <Row k="Origin">
57 + <OriginBadge origin={event.origin} model={event.model_name} prompt={event.prompt_version} />
58 + {llm && (
59 + <span className="ml-2 text-xs text-ink-3">
60 + {event.model_name ?? 'model'}
61 + {event.prompt_version ? ` · prompt ${event.prompt_version}` : ''} — LLM-enriched interpretation of a deterministic change
62 + </span>
63 + )}
64 + </Row>
65 + {event.status !== 'active' && (
66 + <Row k="Status">
67 + <StatusBadge status={event.status} />
68 + </Row>
69 + )}
70 + {event.tags.length > 0 && (
71 + <Row k="Tags">
72 + <span className="flex flex-wrap gap-1">
73 + {[...new Set(event.tags)].map((t) => (
74 + <span key={t} className="mono rounded-[3px] bg-surface-2 px-1 text-[11px] text-ink-2">
75 + {t}
76 + </span>
77 + ))}
78 + </span>
79 + </Row>
80 + )}
81 + </KV>
82 +
83 + <div>
84 + <p className="eyebrow">
85 + Sources <span className="tnum normal-case tracking-normal text-ink-3">({list.length})</span>
86 + </p>
87 + {list.length === 0 ? (
88 + <p className="mt-1 text-xs text-ink-3">The monitored source for this event is not available any more; the observation is kept.</p>
89 + ) : (
90 + <ul className="mt-1.5 divide-y divide-rule border-y border-rule">
91 + {list.map((s, i) => (
92 + <li key={`${s.source_url}-${i}`} className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 py-2 text-[13px]">
93 + <a href={s.source_url} target="_blank" rel="noopener noreferrer" className="link inline-flex min-w-0 max-w-full items-center gap-1 break-all">
94 + {pathOf(s.source_url)} <ExternalLink className="size-3 shrink-0" aria-hidden />
95 + </a>
96 + <span className="text-xs text-ink-3">
97 + {s.surface ? (SURFACE_LABELS[s.surface] ?? s.surface) : s.kind} · detected <span className="tnum">{fmtDateTime(s.detected_at)}</span>
98 + </span>
99 + {s.sensor_id && (
100 + <Link href={routes.sensor(s.sensor_id)} className="mono text-[11px] text-ink-3 hover:text-accent">
101 + sensor {s.sensor_id.slice(0, 12)}…
102 + </Link>
103 + )}
104 + </li>
105 + ))}
106 + </ul>
107 + )}
108 + </div>
109 +
110 + <div className="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
111 + {event.change_id && (
112 + <Link href={routes.change(event.change_id)} className="link">
113 + View block-level change diff →
114 + </Link>
115 + )}
116 + {event.sensor_id && (
117 + <Link href={routes.sensor(event.sensor_id)} className="link">
118 + Sensor
119 + </Link>
120 + )}
121 + <Link href={routes.company(event.company.slug, 'timeline')} className="link">
122 + Company timeline
123 + </Link>
124 + </div>
125 + <p className="mono break-all text-[11px] text-ink-3">
126 + event {event.id}
127 + {event.cluster_id ? ` · cluster ${event.cluster_id}` : ''}
128 + {event.change_id ? ` · change ${event.change_id}` : ''}
129 + {event.sensor_id ? ` · sensor ${event.sensor_id}` : ''}
130 + </p>
131 + </div>
132 + );
133 +}
added apps/web/src/components/events/event-filters.tsx +133 −0
@@ -0,0 +1,133 @@
1 +'use client';
2 +import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3 +import { useState } from 'react';
4 +import { cn } from '@/lib/cn';
5 +import { EVENT_TYPES, eventStyle } from '@/lib/event-styles';
6 +
7 +export type FilterOption = { value: string; label: string };
8 +
9 +/**
10 + * URL-bound filter bar for /events and /live: event-type chips (hued), min importance, min confidence, country, industry,
11 + * origin, sort. Collapsible on mobile. Changing a filter resets `page`.
12 + */
13 +export function EventFilters({ countries = [], industries = [], showSort = true, showConfidence = true, className }: { countries?: FilterOption[]; industries?: FilterOption[]; showSort?: boolean; showConfidence?: boolean; className?: string }) {
14 + const router = useRouter();
15 + const pathname = usePathname();
16 + const sp = useSearchParams();
17 + const [openMobile, setOpenMobile] = useState(false);
18 + const cur = (k: string) => sp.get(k) ?? '';
19 + const set = (patch: Record<string, string | null>) => {
20 + const next = new URLSearchParams(sp.toString());
21 + for (const [k, v] of Object.entries(patch)) {
22 + if (!v) next.delete(k);
23 + else next.set(k, v);
24 + }
25 + next.delete('page');
26 + const q = next.toString();
27 + router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
28 + };
29 + const activeType = cur('event_type');
30 + const activeCount = ['event_type', 'min_importance', 'min_confidence', 'country', 'industry', 'origin'].filter((k) => cur(k)).length;
31 + return (
32 + <div className={cn('space-y-2', className)} data-event-filters>
33 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 md:mx-0 md:flex-wrap md:px-0">
34 + <button type="button" onClick={() => set({ event_type: null })} className="chip-btn" data-on={!activeType}>
35 + All types
36 + </button>
37 + {EVENT_TYPES.map((t) => {
38 + const s = eventStyle(t);
39 + const on = activeType === t;
40 + return (
41 + <button key={t} type="button" onClick={() => set({ event_type: on ? null : t })} className="chip-btn" data-on={on} style={on ? { background: s.color, borderColor: s.color, color: 'var(--canvas)' } : { color: s.color }}>
42 + <span className="inline-block size-1.5 rounded-full" style={{ background: on ? 'var(--canvas)' : s.color }} aria-hidden />
43 + {s.label}
44 + </button>
45 + );
46 + })}
47 + </div>
48 + <div className="flex items-center gap-2 md:hidden">
49 + <button type="button" onClick={() => setOpenMobile((v) => !v)} className="btn btn-sm" aria-expanded={openMobile}>
50 + Filters{activeCount ? ` (${activeCount})` : ''}
51 + </button>
52 + {activeCount > 0 && (
53 + <button type="button" onClick={() => set({ event_type: null, min_importance: null, min_confidence: null, country: null, industry: null, origin: null })} className="btn btn-sm text-xs text-ink-3">
54 + Clear
55 + </button>
56 + )}
57 + </div>
58 + <div className={cn('flex-wrap items-center gap-2', openMobile ? 'flex' : 'hidden md:flex')}>
59 + <label className="flex items-center gap-1.5 text-xs text-ink-3">
60 + Min importance
61 + <select value={cur('min_importance')} onChange={(e) => set({ min_importance: e.target.value || null })} className="field h-9 text-xs" aria-label="Minimum importance">
62 + <option value="">any</option>
63 + <option value="0.3">≥ 30</option>
64 + <option value="0.5">≥ 50</option>
65 + <option value="0.7">≥ 70</option>
66 + <option value="0.85">≥ 85</option>
67 + </select>
68 + </label>
69 + {showConfidence && (
70 + <label className="flex items-center gap-1.5 text-xs text-ink-3">
71 + Min confidence
72 + <select value={cur('min_confidence')} onChange={(e) => set({ min_confidence: e.target.value || null })} className="field h-9 text-xs" aria-label="Minimum confidence">
73 + <option value="">any</option>
74 + <option value="0.5">≥ 50 %</option>
75 + <option value="0.7">≥ 70 %</option>
76 + <option value="0.9">≥ 90 %</option>
77 + </select>
78 + </label>
79 + )}
80 + {countries.length > 0 && (
81 + <label className="flex items-center gap-1.5 text-xs text-ink-3">
82 + Country
83 + <select value={cur('country')} onChange={(e) => set({ country: e.target.value || null })} className="field h-9 max-w-[10rem] text-xs" aria-label="Country">
84 + <option value="">all</option>
85 + {countries.map((c) => (
86 + <option key={c.value} value={c.value}>
87 + {c.label}
88 + </option>
89 + ))}
90 + </select>
91 + </label>
92 + )}
93 + {industries.length > 0 && (
94 + <label className="flex items-center gap-1.5 text-xs text-ink-3">
95 + Industry
96 + <select value={cur('industry')} onChange={(e) => set({ industry: e.target.value || null })} className="field h-9 max-w-[12rem] text-xs" aria-label="Industry">
97 + <option value="">all</option>
98 + {industries.map((c) => (
99 + <option key={c.value} value={c.value}>
100 + {c.label}
101 + </option>
102 + ))}
103 + </select>
104 + </label>
105 + )}
106 + <label className="flex items-center gap-1.5 text-xs text-ink-3">
107 + Origin
108 + <select value={cur('origin')} onChange={(e) => set({ origin: e.target.value || null })} className="field h-9 text-xs" aria-label="Origin">
109 + <option value="">any</option>
110 + <option value="deterministic">deterministic</option>
111 + <option value="llm">llm</option>
112 + <option value="hybrid">hybrid</option>
113 + <option value="backfill">backfill</option>
114 + </select>
115 + </label>
116 + {showSort && (
117 + <label className="flex items-center gap-1.5 text-xs text-ink-3">
118 + Sort
119 + <select value={cur('sort') || 'recent'} onChange={(e) => set({ sort: e.target.value === 'recent' ? null : e.target.value })} className="field h-9 text-xs" aria-label="Sort">
120 + <option value="recent">most recent</option>
121 + <option value="importance">importance</option>
122 + </select>
123 + </label>
124 + )}
125 + {activeCount > 0 && (
126 + <button type="button" onClick={() => set({ event_type: null, min_importance: null, min_confidence: null, country: null, industry: null, origin: null })} className="btn btn-sm hidden text-xs text-ink-3 md:inline-flex">
127 + Clear filters
128 + </button>
129 + )}
130 + </div>
131 + </div>
132 + );
133 +}
added apps/web/src/components/events/event-row.tsx +68 −0
@@ -0,0 +1,68 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { ConfidenceBadge, CountryChip, EventTypeBadge, ImportanceMeter } from '@/components/ui/badges';
4 +import { LiveAgo } from '@/components/ui/live';
5 +import { cn } from '@/lib/cn';
6 +import { eventStyle } from '@/lib/event-styles';
7 +import { fmtTime, truncate } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import type { Event } from '@/lib/types';
10 +import { useEventDrawer } from './event-drawer-context';
11 +
12 +/**
13 + * Dense feed row: hue bar · type chip · title (opens the evidence drawer) · company · importance · confidence · time.
14 + * `variant="feed"` is the live feed (fresh rows animate); `"table"` is the events list; `"timeline"` hides the company.
15 + */
16 +export function EventRow({ event, fresh = false, selected = false, variant = 'feed', showCompany = true, className, id }: { event: Event; fresh?: boolean; selected?: boolean; variant?: 'feed' | 'table' | 'timeline'; showCompany?: boolean; className?: string; id?: string }) {
17 + const { open } = useEventDrawer();
18 + const s = eventStyle(event.event_type);
19 + const retracted = event.status === 'retracted';
20 + return (
21 + <li id={id} data-event-id={event.id} data-selected={selected || undefined} className={cn('group relative grid grid-cols-[3px_minmax(0,1fr)] gap-x-3 border-b border-rule', fresh && 'feed-new', selected && 'bg-surface-2', className)}>
22 + <span className="my-2 rounded-full" style={{ background: s.color }} aria-hidden />
23 + <div className={cn('min-w-0 py-2.5', variant === 'timeline' ? 'pr-1' : 'pr-0')}>
24 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
25 + <EventTypeBadge type={event.event_type} subtype={variant !== 'feed' ? event.event_subtype : undefined} small />
26 + {showCompany && (
27 + <Link href={routes.company(event.company.slug)} className="max-w-[55%] truncate text-[13px] font-medium text-ink hover:text-accent">
28 + {event.company.display_name}
29 + </Link>
30 + )}
31 + {showCompany && <CountryChip code={event.company.country} />}
32 + <span className="ml-auto inline-flex items-center gap-2 text-[11px] text-ink-3">
33 + <ImportanceMeter importance={event.importance} />
34 + {variant === 'timeline' ? <span className="tnum">{fmtTime(event.detected_at)} UTC</span> : <LiveAgo at={event.detected_at} tick={variant === 'feed' ? 1000 : 30000} />}
35 + </span>
36 + </div>
37 + <button type="button" onClick={() => open(event)} className={cn('mt-1 block w-full text-left text-[14px] leading-snug text-ink hover:text-accent', retracted && 'line-through decoration-danger/60')} data-open-event>
38 + {event.title}
39 + </button>
40 + {event.summary && variant !== 'feed' && <p className="mt-0.5 text-[13px] leading-snug text-ink-2">{truncate(event.summary, 200)}</p>}
41 + <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-ink-3">
42 + <ConfidenceBadge label={event.confidence_label} />
43 + {event.old_value && event.new_value && (
44 + <span className="min-w-0 truncate">
45 + <span className="text-ink-3 line-through">{truncate(event.old_value, 28)}</span> → <span className="text-ink-2">{truncate(event.new_value, 28)}</span>
46 + </span>
47 + )}
48 + {event.origin !== 'deterministic' && <span className="uppercase tracking-wider">{event.origin}</span>}
49 + {retracted && <span className="text-danger">retracted</span>}
50 + <Link href={routes.event(event.id)} className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 hover:text-accent">
51 + Details →
52 + </Link>
53 + </div>
54 + </div>
55 + </li>
56 + );
57 +}
58 +
59 +export function EventList({ events, className, variant = 'table', showCompany = true, emptyLabel = 'No monitored evidence available yet.' }: { events: Event[]; className?: string; variant?: 'feed' | 'table' | 'timeline'; showCompany?: boolean; emptyLabel?: string }) {
60 + if (!events.length) return <p className={cn('border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3', className)}>{emptyLabel}</p>;
61 + return (
62 + <ul className={cn('border-t border-rule', className)}>
63 + {events.map((e) => (
64 + <EventRow key={e.id} event={e} variant={variant} showCompany={showCompany} />
65 + ))}
66 + </ul>
67 + );
68 +}
added apps/web/src/components/layout/density.tsx +43 −0
@@ -0,0 +1,43 @@
1 +'use client';
2 +import { Rows3, Rows4 } from 'lucide-react';
3 +import { useEffect, useState } from 'react';
4 +import { cn } from '@/lib/cn';
5 +import { DENSITY_KEY } from '@/lib/prepaint';
6 +
7 +export type Density = 'comfortable' | 'compact';
8 +
9 +export function readDensity(): Density {
10 + try {
11 + return localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'comfortable';
12 + } catch {
13 + return 'comfortable';
14 + }
15 +}
16 +export function setDensity(d: Density) {
17 + try {
18 + if (d === 'compact') localStorage.setItem(DENSITY_KEY, d);
19 + else localStorage.removeItem(DENSITY_KEY);
20 + } catch {
21 + /* ignore */
22 + }
23 + if (d === 'compact') document.documentElement.setAttribute('data-density', d);
24 + else document.documentElement.removeAttribute('data-density');
25 + window.dispatchEvent(new CustomEvent('ca-density-change'));
26 +}
27 +
28 +export function DensityToggle({ className }: { className?: string }) {
29 + const [d, setD] = useState<Density>('comfortable');
30 + useEffect(() => {
31 + const read = () => setD(readDensity());
32 + read();
33 + window.addEventListener('ca-density-change', read);
34 + return () => window.removeEventListener('ca-density-change', read);
35 + }, []);
36 + const next: Density = d === 'compact' ? 'comfortable' : 'compact';
37 + const Icon = d === 'compact' ? Rows4 : Rows3;
38 + return (
39 + <button type="button" onClick={() => setDensity(next)} className={cn('hidden size-10 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:flex', className)} aria-label={`Density: ${d} — switch to ${next}`} title={`Density: ${d}`}>
40 + <Icon className="size-[18px]" aria-hidden />
41 + </button>
42 + );
43 +}
added apps/web/src/components/layout/mobile-tab-bar.tsx +53 −0
@@ -0,0 +1,53 @@
1 +'use client';
2 +import { Activity, Bookmark, Home, Search, Trophy } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { cn } from '@/lib/cn';
6 +import { useOpenSearch } from './search-context';
7 +
8 +/** Bottom tab bar (< lg): Home · Live · Search · Rankings · Watchlist (spec §87). Safe-area aware. */
9 +export function MobileTabBar() {
10 + const pathname = usePathname();
11 + const openSearch = useOpenSearch();
12 + const isActive = (href: string) => (href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/'));
13 + const cls = (active: boolean) => cn('flex h-full w-full flex-col items-center justify-center gap-0.5 text-[10.5px]', active ? 'text-accent' : 'text-ink-2');
14 + return (
15 + <nav aria-label="Primary (mobile)" className="safe-bottom fixed inset-x-0 bottom-0 z-[46] border-t border-rule bg-canvas/95 backdrop-blur-md lg:hidden">
16 + <ul className="grid h-[var(--tabbar-h)] grid-cols-5">
17 + <li className="min-w-0">
18 + <Link href="/" aria-current={isActive('/') ? 'page' : undefined} className={cls(isActive('/'))}>
19 + <Home size={21} aria-hidden strokeWidth={isActive('/') ? 2.25 : 1.75} />
20 + <span className="truncate">Home</span>
21 + </Link>
22 + </li>
23 + <li className="min-w-0">
24 + <Link href="/live" aria-current={isActive('/live') ? 'page' : undefined} className={cls(isActive('/live'))}>
25 + <span className="relative">
26 + <Activity size={21} aria-hidden strokeWidth={isActive('/live') ? 2.25 : 1.75} />
27 + <span className="dot absolute -right-1 -top-0.5 size-[6px]" aria-hidden />
28 + </span>
29 + <span className="truncate">Live</span>
30 + </Link>
31 + </li>
32 + <li className="min-w-0">
33 + <button type="button" onClick={openSearch} className={cls(false)} aria-label="Search">
34 + <Search size={21} aria-hidden strokeWidth={1.75} />
35 + <span className="truncate">Search</span>
36 + </button>
37 + </li>
38 + <li className="min-w-0">
39 + <Link href="/rankings" aria-current={isActive('/rankings') ? 'page' : undefined} className={cls(isActive('/rankings'))}>
40 + <Trophy size={21} aria-hidden strokeWidth={isActive('/rankings') ? 2.25 : 1.75} />
41 + <span className="truncate">Rankings</span>
42 + </Link>
43 + </li>
44 + <li className="min-w-0">
45 + <Link href="/watchlist" aria-current={isActive('/watchlist') ? 'page' : undefined} className={cls(isActive('/watchlist'))}>
46 + <Bookmark size={21} aria-hidden strokeWidth={isActive('/watchlist') ? 2.25 : 1.75} />
47 + <span className="truncate">Watchlist</span>
48 + </Link>
49 + </li>
50 + </ul>
51 + </nav>
52 + );
53 +}
added apps/web/src/components/layout/search-context.tsx +34 −0
@@ -0,0 +1,34 @@
1 +'use client';
2 +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
3 +
4 +const Ctx = createContext<{ open: boolean; setOpen: (v: boolean) => void }>({ open: false, setOpen: () => undefined });
5 +
6 +/** Global ⌘K / "/" search dialog state. */
7 +export function SearchProvider({ children }: { children: ReactNode }) {
8 + const [open, setOpen] = useState(false);
9 + useEffect(() => {
10 + const onKey = (e: KeyboardEvent) => {
11 + const target = e.target as HTMLElement | null;
12 + const typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable);
13 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
14 + e.preventDefault();
15 + setOpen((o) => !o);
16 + } else if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey) {
17 + e.preventDefault();
18 + setOpen(true);
19 + }
20 + };
21 + window.addEventListener('keydown', onKey);
22 + return () => window.removeEventListener('keydown', onKey);
23 + }, []);
24 + const value = useMemo(() => ({ open, setOpen }), [open]);
25 + return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
26 +}
27 +
28 +export function useSearch() {
29 + return useContext(Ctx);
30 +}
31 +export function useOpenSearch() {
32 + const { setOpen } = useContext(Ctx);
33 + return useCallback(() => setOpen(true), [setOpen]);
34 +}
added apps/web/src/components/layout/search-dialog.tsx +250 −0
@@ -0,0 +1,250 @@
1 +'use client';
2 +import { ArrowRight, Building2, Clock, CornerDownLeft, Factory, Globe2, Search, Tag, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useCallback, useEffect, useRef, useState } from 'react';
6 +import { clientApi } from '@/lib/client-api';
7 +import { cn } from '@/lib/cn';
8 +import { EXAMPLE_QUERIES, primaryNav, routes } from '@/lib/site';
9 +import type { Suggestion } from '@/lib/types';
10 +import { useSearch } from './search-context';
11 +
12 +const RECENT_KEY = 'ca-recent';
13 +const RECENT_MAX = 6;
14 +function readRecent(): Suggestion[] {
15 + try {
16 + const arr = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as unknown;
17 + return Array.isArray(arr) ? (arr.filter((x) => x && typeof (x as Suggestion).href === 'string') as Suggestion[]).slice(0, RECENT_MAX) : [];
18 + } catch {
19 + return [];
20 + }
21 +}
22 +function pushRecent(s: Suggestion) {
23 + try {
24 + localStorage.setItem(RECENT_KEY, JSON.stringify([s, ...readRecent().filter((r) => r.href !== s.href)].slice(0, RECENT_MAX)));
25 + } catch {
26 + /* ignore */
27 + }
28 +}
29 +
30 +const ICON = { company: Building2, industry: Factory, country: Globe2, event_type: Tag } as const;
31 +
32 +type Row = { key: string; href: string; node: React.ReactNode; section: string; run?: () => void };
33 +
34 +/**
35 + * ⌘K search: `/search/suggest` (debounced 120 ms) grouped by kind, recent picks, example natural-language queries and a
36 + * "Search everything" row. Keyboard: ↑↓ move · ↵ open · esc close. Mobile: full-screen sheet.
37 + */
38 +export function SearchDialog() {
39 + const { open, setOpen } = useSearch();
40 + const router = useRouter();
41 + const [q, setQ] = useState('');
42 + const [items, setItems] = useState<Suggestion[]>([]);
43 + const [active, setActive] = useState(0);
44 + const [loading, setLoading] = useState(false);
45 + const [failed, setFailed] = useState(false);
46 + const [recent, setRecent] = useState<Suggestion[]>([]);
47 + const inputRef = useRef<HTMLInputElement>(null);
48 + const listRef = useRef<HTMLUListElement>(null);
49 + const term = q.trim();
50 +
51 + useEffect(() => {
52 + listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
53 + }, [active]);
54 +
55 + useEffect(() => {
56 + if (open) {
57 + setTimeout(() => inputRef.current?.focus(), 20);
58 + document.body.style.overflow = 'hidden';
59 + setRecent(readRecent());
60 + setActive(0);
61 + } else {
62 + document.body.style.overflow = '';
63 + setQ('');
64 + setItems([]);
65 + setFailed(false);
66 + }
67 + return () => {
68 + document.body.style.overflow = '';
69 + };
70 + }, [open]);
71 +
72 + useEffect(() => {
73 + if (!open || term.length < 1) {
74 + setItems([]);
75 + return;
76 + }
77 + const ctrl = new AbortController();
78 + const t = setTimeout(async () => {
79 + setLoading(true);
80 + try {
81 + const res = await clientApi.suggest(term, ctrl.signal);
82 + setItems(res.items ?? []);
83 + setFailed(false);
84 + setActive(0);
85 + } catch (e) {
86 + if ((e as Error).name !== 'AbortError') setFailed(true);
87 + } finally {
88 + setLoading(false);
89 + }
90 + }, 120);
91 + return () => {
92 + clearTimeout(t);
93 + ctrl.abort();
94 + };
95 + }, [term, open]);
96 +
97 + const close = useCallback(() => setOpen(false), [setOpen]);
98 + const go = useCallback(
99 + (href: string) => {
100 + router.push(href);
101 + close();
102 + },
103 + [router, close],
104 + );
105 +
106 + if (!open) return null;
107 +
108 + const rows: Row[] = [];
109 + const node = (s: Suggestion, icon?: React.ReactNode) => {
110 + const I = ICON[s.kind] ?? Tag;
111 + return (
112 + <>
113 + {icon ?? <I className="size-4 shrink-0 text-ink-3" aria-hidden />}
114 + <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.label}</span>
115 + {s.sublabel && <span className="hidden max-w-[40%] truncate text-xs text-ink-3 sm:block">{s.sublabel}</span>}
116 + <span className="text-[10px] uppercase tracking-wider text-ink-3">{s.kind.replace('_', ' ')}</span>
117 + </>
118 + );
119 + };
120 + const order: Suggestion['kind'][] = ['company', 'industry', 'country', 'event_type'];
121 + for (const k of order) for (const s of items.filter((i) => i.kind === k)) rows.push({ key: `s-${s.href}`, href: s.href, section: k === 'company' ? 'Companies' : k === 'industry' ? 'Industries' : k === 'country' ? 'Countries' : 'Event types', node: node(s), run: () => pushRecent(s) });
122 + if (!term && recent.length) for (const r of recent) rows.push({ key: `r-${r.href}`, href: r.href, section: 'Recent', node: node(r, <Clock className="size-4 shrink-0 text-ink-3" aria-hidden />) });
123 + if (term)
124 + rows.push({
125 + key: '__all',
126 + href: routes.search(term),
127 + section: 'Search',
128 + node: (
129 + <>
130 + <Search className="size-4 text-accent" aria-hidden />
131 + <span className="flex-1 text-sm text-accent">{/\s/.test(term) ? `Ask Company Atlas: “${term}”` : `Search everything for “${term}”`}</span>
132 + <CornerDownLeft className="size-3.5 text-ink-3" aria-hidden />
133 + </>
134 + ),
135 + });
136 + const clamp = Math.min(active, Math.max(0, rows.length - 1));
137 + const onKey = (e: React.KeyboardEvent) => {
138 + if (e.key === 'ArrowDown') {
139 + e.preventDefault();
140 + setActive((a) => Math.min(a + 1, rows.length - 1));
141 + } else if (e.key === 'ArrowUp') {
142 + e.preventDefault();
143 + setActive((a) => Math.max(a - 1, 0));
144 + } else if (e.key === 'Enter') {
145 + e.preventDefault();
146 + const it = rows[clamp];
147 + if (it) {
148 + it.run?.();
149 + go(it.href);
150 + } else if (term) go(routes.search(term));
151 + } else if (e.key === 'Escape') close();
152 + };
153 + let lastSection = '';
154 + return (
155 + <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[10vh]" role="dialog" aria-modal="true" aria-label="Search" onClick={close} data-palette>
156 + <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[72vh] md:w-[720px] md:rounded-lg" onClick={(e) => e.stopPropagation()}>
157 + <div className="flex items-center gap-3 border-b border-rule px-4 py-2.5">
158 + <Search className="size-5 shrink-0 text-ink-3" aria-hidden />
159 + <input
160 + ref={inputRef}
161 + value={q}
162 + onChange={(e) => {
163 + setQ(e.target.value);
164 + setActive(0);
165 + }}
166 + onKeyDown={onKey}
167 + placeholder="Search companies, industries, countries… or ask a question"
168 + className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"
169 + autoComplete="off"
170 + spellCheck={false}
171 + aria-label="Search"
172 + role="combobox"
173 + aria-expanded={rows.length > 0}
174 + aria-controls="palette-list"
175 + aria-activedescendant={rows[clamp] ? `pal-${rows[clamp].key}` : undefined}
176 + data-palette-input
177 + />
178 + <button type="button" onClick={close} className="-mr-1 flex size-11 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close">
179 + <X className="size-5" aria-hidden />
180 + </button>
181 + </div>
182 + <div className="scrollbar-thin flex-1 overflow-y-auto">
183 + <ul id="palette-list" ref={listRef} className="py-1" role="listbox">
184 + {failed && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>}
185 + {term && !loading && !failed && items.length === 0 && <li className="px-4 py-2 text-xs text-ink-3">No direct match — search everything below.</li>}
186 + {rows.map((r, i) => {
187 + const header = r.section !== lastSection;
188 + lastSection = r.section;
189 + return (
190 + <li key={r.key} id={`pal-${r.key}`} role="option" aria-selected={i === clamp} data-palette-row>
191 + {header && <p className="eyebrow px-4 pb-1 pt-3">{r.section}</p>}
192 + <Link
193 + href={r.href}
194 + onClick={() => {
195 + r.run?.();
196 + close();
197 + }}
198 + onMouseEnter={() => setActive(i)}
199 + className={cn('flex min-h-[44px] w-full items-center gap-3 px-4 py-2 text-left', i === clamp ? 'bg-surface-3' : 'hover:bg-surface-2')}
200 + >
201 + {r.node}
202 + {i === clamp && <kbd className="mono hidden text-[10px] text-ink-3 sm:block">↵</kbd>}
203 + </Link>
204 + </li>
205 + );
206 + })}
207 + </ul>
208 + {!term && (
209 + <div className="border-t border-rule px-4 py-4">
210 + <p className="eyebrow mb-2">Try asking</p>
211 + <ul className="flex flex-wrap gap-2">
212 + {EXAMPLE_QUERIES.map((ex) => (
213 + <li key={ex}>
214 + <button type="button" onClick={() => setQ(ex)} className="min-h-11 border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:min-h-9">
215 + {ex}
216 + </button>
217 + </li>
218 + ))}
219 + </ul>
220 + <p className="eyebrow mt-5 mb-2">Browse</p>
221 + <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-4">
222 + {primaryNav
223 + .filter((n) => n.href !== '/')
224 + .map((n) => (
225 + <li key={n.href}>
226 + <Link href={n.href} onClick={close} className="flex h-11 items-center gap-2 text-sm text-ink-2 hover:text-accent md:h-10">
227 + <ArrowRight className="size-3.5" aria-hidden /> {n.label}
228 + </Link>
229 + </li>
230 + ))}
231 + </ul>
232 + </div>
233 + )}
234 + </div>
235 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-rule px-4 py-2 text-[11px] text-ink-3">
236 + <span>
237 + <kbd className="mono border border-rule px-1">↵</kbd> open
238 + </span>
239 + <span>
240 + <kbd className="mono border border-rule px-1">↑↓</kbd> move
241 + </span>
242 + <span className="hidden sm:inline">Natural-language questions are routed to /ask and always link back to events and sources.</span>
243 + <span className="ml-auto">
244 + <kbd className="mono border border-rule px-1">esc</kbd> close
245 + </span>
246 + </div>
247 + </div>
248 + </div>
249 + );
250 +}
added apps/web/src/components/layout/site-footer.tsx +59 −0
@@ -0,0 +1,59 @@
1 +import Link from 'next/link';
2 +import { LogoMark } from '@/components/brand/logo';
3 +import { AUTHOR_NAME, CONTACT_EMAIL, footerGroups, HOST_NAME, HOST_URL, SITE_URL } from '@/lib/site';
4 +
5 +export function SiteFooter() {
6 + return (
7 + <footer className="mt-16 border-t border-rule">
8 + <div className="container-x mx-auto max-w-[1600px] py-10">
9 + <div className="grid gap-8 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
10 + <div>
11 + <p className="flex items-center gap-2 text-ink">
12 + <LogoMark size={22} />
13 + <span className="tracking-tight">
14 + <span className="font-medium text-ink-2">Company</span> <span className="font-bold">Atlas</span>
15 + </span>
16 + </p>
17 + <p className="mt-3 max-w-sm text-sm leading-relaxed text-ink-2">
18 + A continuously updated corporate observation network. Every event links to the public page where it was detected; inferred facts carry a confidence label; history is never overwritten.
19 + </p>
20 + <p className="mono mt-3 text-xs text-ink-3">{SITE_URL.replace(/^https?:\/\//, '')}/api/v1</p>
21 + </div>
22 + {footerGroups.map((g) => (
23 + <div key={g.label}>
24 + <p className="eyebrow mb-2">{g.label}</p>
25 + <ul className="space-y-1 text-sm">
26 + {g.items.map((n) => (
27 + <li key={n.href + n.label}>
28 + {n.href.startsWith('mailto:') ? (
29 + <a href={n.href} className="inline-block py-0.5 text-ink-2 hover:text-ink">
30 + {n.label}
31 + </a>
32 + ) : (
33 + <Link href={n.href} className="inline-block py-0.5 text-ink-2 hover:text-ink">
34 + {n.label}
35 + </Link>
36 + )}
37 + </li>
38 + ))}
39 + </ul>
40 + </div>
41 + ))}
42 + </div>
43 + <div className="mt-10 flex flex-col gap-2 border-t border-rule pt-5 text-xs text-ink-3 md:flex-row md:items-center md:justify-between">
44 + <p>
45 + Built by <span className="text-ink-2">{AUTHOR_NAME}</span> ·{' '}
46 + <a href={`mailto:${CONTACT_EMAIL}`} className="text-ink-2 hover:text-ink">
47 + {CONTACT_EMAIL}
48 + </a>{' '}
49 + · Hosted on{' '}
50 + <a href={HOST_URL} className="text-ink-2 hover:text-ink" rel="noopener noreferrer">
51 + {HOST_NAME}
52 + </a>
53 + </p>
54 + <p>Data provenance: observations of publicly accessible corporate web pages, collected by {`CompanyAtlasBot`} in accordance with robots.txt. Interpretations are labelled, never asserted as fact.</p>
55 + </div>
56 + </div>
57 + </footer>
58 + );
59 +}
added apps/web/src/components/layout/site-header.tsx +46 −0
@@ -0,0 +1,46 @@
1 +'use client';
2 +import { Search } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { Wordmark } from '@/components/brand/logo';
6 +import { cn } from '@/lib/cn';
7 +import { primaryNav, routes } from '@/lib/site';
8 +import { DensityToggle } from './density';
9 +import { useOpenSearch } from './search-context';
10 +import { ThemeToggle } from './theme';
11 +
12 +/** Header: lockup · Home Live Companies Events Rankings Industries Countries · ⌘K search · theme · density. */
13 +export function SiteHeader() {
14 + const pathname = usePathname();
15 + const openSearch = useOpenSearch();
16 + const isActive = (href: string) => (href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/'));
17 + return (
18 + <header className="sticky top-0 z-40 border-b border-rule bg-canvas/85 backdrop-blur-md">
19 + <div className="container-x mx-auto flex h-[var(--header-h)] max-w-[1600px] items-center gap-3">
20 + <Link href={routes.home()} className="flex h-11 items-center" aria-label="Company Atlas home">
21 + <Wordmark />
22 + </Link>
23 + <nav aria-label="Primary" className="ml-3 hidden items-center gap-0.5 lg:flex">
24 + {primaryNav.map((n) => (
25 + <Link key={n.href} href={n.href} aria-current={isActive(n.href) ? 'page' : undefined} className={cn('flex h-9 items-center gap-1.5 rounded-sm px-2.5 text-[13.5px] transition-colors', isActive(n.href) ? 'font-medium text-ink' : 'text-ink-2 hover:text-ink')}>
26 + {n.href === '/live' && <span className="dot" aria-hidden />}
27 + {n.label}
28 + </Link>
29 + ))}
30 + </nav>
31 + <div className="ml-auto flex items-center gap-1">
32 + <button type="button" onClick={openSearch} className="hidden h-9 min-w-[250px] items-center gap-2 rounded-sm border border-rule bg-surface px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 md:flex" aria-label="Open search" data-open-palette>
33 + <Search className="size-4" aria-hidden />
34 + <span className="flex-1 text-left">Search or ask…</span>
35 + <kbd className="mono rounded-[3px] border border-rule px-1.5 py-0.5 text-[10px]">⌘K</kbd>
36 + </button>
37 + <button type="button" onClick={openSearch} className="flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:hidden" aria-label="Open search">
38 + <Search className="size-5" aria-hidden />
39 + </button>
40 + <ThemeToggle />
41 + <DensityToggle />
42 + </div>
43 + </div>
44 + </header>
45 + );
46 +}
added apps/web/src/components/layout/theme.tsx +54 −0
@@ -0,0 +1,54 @@
1 +'use client';
2 +import { Monitor, Moon, Sun } from 'lucide-react';
3 +import { useEffect, useState } from 'react';
4 +import { cn } from '@/lib/cn';
5 +import { THEME_KEY } from '@/lib/prepaint';
6 +import { THEME_DARK, THEME_LIGHT } from '@/lib/site';
7 +
8 +export type ThemePref = 'light' | 'dark' | 'system';
9 +
10 +function apply(pref: ThemePref) {
11 + const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
12 + const t = pref === 'system' ? (dark ? 'dark' : 'light') : pref;
13 + document.documentElement.setAttribute('data-theme', t);
14 + document.documentElement.style.colorScheme = t;
15 + const meta = document.querySelector('meta[name="theme-color"]');
16 + if (meta) meta.setAttribute('content', t === 'dark' ? THEME_DARK : THEME_LIGHT);
17 +}
18 +
19 +export function useTheme(): [ThemePref, (p: ThemePref) => void] {
20 + const [pref, setPref] = useState<ThemePref>('system');
21 + useEffect(() => {
22 + const p = localStorage.getItem(THEME_KEY);
23 + if (p === 'light' || p === 'dark') setPref(p);
24 + // Error/not-found shells are client-rendered from scratch and can lose the prepaint attribute: re-apply.
25 + if (!document.documentElement.getAttribute('data-theme')) apply(p === 'light' || p === 'dark' ? p : 'system');
26 + const m = window.matchMedia('(prefers-color-scheme: dark)');
27 + const onChange = () => {
28 + const cur = localStorage.getItem(THEME_KEY);
29 + if (cur !== 'light' && cur !== 'dark') apply('system');
30 + };
31 + m.addEventListener('change', onChange);
32 + return () => m.removeEventListener('change', onChange);
33 + }, []);
34 + const set = (p: ThemePref) => {
35 + setPref(p);
36 + if (p === 'system') localStorage.removeItem(THEME_KEY);
37 + else localStorage.setItem(THEME_KEY, p);
38 + apply(p);
39 + };
40 + return [pref, set];
41 +}
42 +
43 +/** Three-state toggle (system → light → dark). Touch target 44 px. */
44 +export function ThemeToggle({ className }: { className?: string }) {
45 + const [pref, set] = useTheme();
46 + const next: ThemePref = pref === 'system' ? 'light' : pref === 'light' ? 'dark' : 'system';
47 + const Icon = pref === 'system' ? Monitor : pref === 'light' ? Sun : Moon;
48 + const label = pref === 'system' ? 'Theme: system' : pref === 'light' ? 'Theme: light' : 'Theme: dark';
49 + return (
50 + <button type="button" onClick={() => set(next)} className={cn('flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:size-10', className)} aria-label={`${label} — switch`} title={label}>
51 + <Icon className="size-[18px]" aria-hidden />
52 + </button>
53 + );
54 +}
added apps/web/src/components/live/counters.tsx +110 −0
@@ -0,0 +1,110 @@
1 +'use client';
2 +import { useEffect, useRef, useState } from 'react';
3 +import { LiveStatus } from '@/components/ui/live';
4 +import { cn } from '@/lib/cn';
5 +import { fmtInt } from '@/lib/format';
6 +import type { Stats } from '@/lib/types';
7 +
8 +const nf = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
9 +
10 +/** Number that counts up to `value` on mount (700 ms, eased) and ticks green when the value changes later. */
11 +export function CountUp({ value, className, duration = 700 }: { value: number | null | undefined; className?: string; duration?: number }) {
12 + const [shown, setShown] = useState<number | null>(value ?? null);
13 + const [tick, setTick] = useState(0);
14 + const prev = useRef<number | null>(null);
15 + useEffect(() => {
16 + if (value === null || value === undefined) return;
17 + const from = prev.current ?? (value > 50 ? Math.round(value * 0.92) : 0);
18 + const to = value;
19 + if (prev.current !== null && prev.current !== value) setTick((t) => t + 1);
20 + prev.current = value;
21 + if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
22 + setShown(to);
23 + return;
24 + }
25 + const t0 = performance.now();
26 + let raf = 0;
27 + const step = (t: number) => {
28 + const p = Math.min(1, (t - t0) / duration);
29 + const e = 1 - Math.pow(1 - p, 3);
30 + setShown(Math.round(from + (to - from) * e));
31 + if (p < 1) raf = requestAnimationFrame(step);
32 + };
33 + raf = requestAnimationFrame(step);
34 + return () => cancelAnimationFrame(raf);
35 + }, [value, duration]);
36 + return (
37 + <span key={tick} className={cn('tnum', tick > 0 && 'counter-tick', className)} suppressHydrationWarning>
38 + {shown === null ? '—' : nf.format(shown)}
39 + </span>
40 + );
41 +}
42 +
43 +const COUNTERS: { key: keyof Stats; label: string; hint?: (s: Stats) => string | null }[] = [
44 + { key: 'companies', label: 'Companies', hint: (s) => (s.companies_active ? `${fmtInt(s.companies_active)} active` : null) },
45 + { key: 'sensors', label: 'Sensors', hint: (s) => (s.sensors_active ? `${fmtInt(s.sensors_active)} active` : null) },
46 + { key: 'observations', label: 'Observations', hint: (s) => (s.observations_today ? `+${fmtInt(s.observations_today)} today` : null) },
47 + { key: 'changes', label: 'Changes', hint: (s) => (s.meaningful_changes ? `${fmtInt(s.meaningful_changes)} meaningful` : null) },
48 + { key: 'events', label: 'Structured events', hint: (s) => (s.events_today ? `+${fmtInt(s.events_today)} today` : null) },
49 +];
50 +
51 +/** Hero counters: server value first, then live refresh from `/api/v1/stats` every 60 s. */
52 +export function LiveCounters({ stats: initial, className }: { stats: Stats | null; className?: string }) {
53 + const [stats, setStats] = useState<Stats | null>(initial);
54 + const [updatedAt, setUpdatedAt] = useState<number | null>(null);
55 + const [ok, setOk] = useState(true);
56 + useEffect(() => {
57 + setUpdatedAt(Date.now());
58 + let cancelled = false;
59 + const refresh = async () => {
60 + try {
61 + const res = await fetch('/api/v1/stats', { headers: { accept: 'application/json' }, cache: 'no-store' });
62 + if (!res.ok) throw new Error(String(res.status));
63 + const s = (await res.json()) as Stats;
64 + if (!cancelled) {
65 + setStats(s);
66 + setUpdatedAt(Date.now());
67 + setOk(true);
68 + }
69 + } catch {
70 + if (!cancelled) setOk(false);
71 + }
72 + };
73 + const first = setTimeout(refresh, 1500);
74 + const t = setInterval(refresh, 60_000);
75 + return () => {
76 + cancelled = true;
77 + clearTimeout(first);
78 + clearInterval(t);
79 + };
80 + }, []);
81 + if (!stats)
82 + return (
83 + <div className={cn('border-y border-rule py-4 text-sm text-ink-3', className)} role="status">
84 + Platform counters are temporarily unavailable.
85 + </div>
86 + );
87 + return (
88 + <div className={className} data-live-counters>
89 + <div className="grid grid-cols-2 gap-x-6 border-y border-rule sm:grid-cols-3 lg:grid-cols-5 [&>*]:border-b [&>*]:border-rule lg:[&>*]:border-b-0">
90 + {COUNTERS.map((c) => {
91 + const v = stats[c.key];
92 + const hint = c.hint?.(stats);
93 + return (
94 + <div key={c.key} className="min-w-0 py-3 md:py-4">
95 + <p className="eyebrow">{c.label}</p>
96 + <p className="mt-1 text-[26px] font-semibold leading-none tracking-tight md:text-[34px]">
97 + <CountUp value={typeof v === 'number' ? v : null} />
98 + </p>
99 + <p className="mt-1.5 min-h-4 text-xs text-ink-3">{hint ?? ''}</p>
100 + </div>
101 + );
102 + })}
103 + </div>
104 + <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">
105 + <LiveStatus updatedAt={updatedAt} connected={ok} />
106 + {stats.last_observation_at && <span>last observation recorded at {new Date(stats.last_observation_at).toISOString().replace('T', ' ').slice(0, 19)} UTC</span>}
107 + </div>
108 + </div>
109 + );
110 +}
added apps/web/src/components/live/live-feed.tsx +253 −0
@@ -0,0 +1,253 @@
1 +'use client';
2 +import { Pause, Play, RotateCcw } from 'lucide-react';
3 +import { useSearchParams } from 'next/navigation';
4 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5 +import { useEventDrawer } from '@/components/events/event-drawer-context';
6 +import { EventRow } from '@/components/events/event-row';
7 +import { LiveStatus } from '@/components/ui/live';
8 +import { clientApi } from '@/lib/client-api';
9 +import { cn } from '@/lib/cn';
10 +import type { Event } from '@/lib/types';
11 +
12 +const MAX_ROWS = 300;
13 +
14 +function matches(e: Event, f: { event_type?: string; min_importance?: number; country?: string; industry?: string; min_confidence?: number }): boolean {
15 + if (f.event_type && e.event_type !== f.event_type) return false;
16 + if (f.min_importance !== undefined && (e.importance > 1 ? e.importance / 100 : e.importance) < f.min_importance) return false;
17 + if (f.min_confidence !== undefined && e.confidence < f.min_confidence) return false;
18 + if (f.country && (e.company.country ?? '').toUpperCase() !== f.country.toUpperCase()) return false;
19 + return true;
20 +}
21 +
22 +/**
23 + * Live Activity Feed. Seeds with server-rendered events, then subscribes to `/api/v1/live/stream` (SSE, `event: event`).
24 + * New rows prepend with a soft highlight; when paused (hover or button) they buffer behind an "N new" pill.
25 + * Keyboard j/k moves a selection, Enter opens the evidence drawer. Filters come from the URL (`?event_type=&min_importance=…`)
26 + * and are applied client-side to the stream as well. Falls back to polling `/live?since=` when EventSource fails.
27 + */
28 +export function LiveFeed({ initial, limit = 60, compact = false, className, showControls = true, stickyPill = true }: { initial: Event[]; limit?: number; compact?: boolean; className?: string; showControls?: boolean; stickyPill?: boolean }) {
29 + const sp = useSearchParams();
30 + const filters = useMemo(
31 + () => ({
32 + event_type: sp.get('event_type') ?? undefined,
33 + min_importance: sp.get('min_importance') ? Number(sp.get('min_importance')) : undefined,
34 + min_confidence: sp.get('min_confidence') ? Number(sp.get('min_confidence')) : undefined,
35 + country: sp.get('country') ?? undefined,
36 + industry: sp.get('industry') ?? undefined,
37 + }),
38 + [sp],
39 + );
40 + const [events, setEvents] = useState<Event[]>(() => initial.filter((e) => matches(e, filters)).slice(0, limit));
41 + const [buffer, setBuffer] = useState<Event[]>([]);
42 + const [fresh, setFresh] = useState<Set<string>>(new Set());
43 + const [paused, setPaused] = useState(false);
44 + const [hovering, setHovering] = useState(false);
45 + const [connected, setConnected] = useState(false);
46 + const [updatedAt, setUpdatedAt] = useState<number | null>(null);
47 + const [selected, setSelected] = useState<number>(-1);
48 + const seen = useRef<Set<string>>(new Set(initial.map((e) => e.id)));
49 + const latest = useRef<string | null>(initial[0]?.detected_at ?? null);
50 + const { open } = useEventDrawer();
51 + const listRef = useRef<HTMLUListElement>(null);
52 + const holding = paused || hovering;
53 + const holdingRef = useRef(holding);
54 + holdingRef.current = holding;
55 + const filtersRef = useRef(filters);
56 + filtersRef.current = filters;
57 +
58 + // re-seed when filters change (server re-renders `initial` on navigation)
59 + useEffect(() => {
60 + setEvents(initial.filter((e) => matches(e, filters)).slice(0, limit));
61 + seen.current = new Set(initial.map((e) => e.id));
62 + setBuffer([]);
63 + }, [initial, filters, limit]);
64 +
65 + const ingest = useCallback(
66 + (incoming: Event[]) => {
67 + const fresh = incoming.filter((e) => !seen.current.has(e.id) && matches(e, filtersRef.current));
68 + if (!fresh.length) return;
69 + for (const e of fresh) seen.current.add(e.id);
70 + const newest = fresh.map((e) => e.detected_at).sort().pop();
71 + if (newest && (!latest.current || newest > latest.current)) latest.current = newest;
72 + setUpdatedAt(Date.now());
73 + if (holdingRef.current) {
74 + setBuffer((b) => [...fresh, ...b].slice(0, MAX_ROWS));
75 + return;
76 + }
77 + setEvents((prev) => [...fresh, ...prev].slice(0, Math.max(limit, MAX_ROWS)));
78 + setFresh((s) => {
79 + const n = new Set(s);
80 + for (const e of fresh) n.add(e.id);
81 + return n;
82 + });
83 + setTimeout(() => setFresh((s) => {
84 + const n = new Set(s);
85 + for (const e of fresh) n.delete(e.id);
86 + return n;
87 + }), 2000);
88 + },
89 + [limit],
90 + );
91 +
92 + // SSE with polling fallback
93 + useEffect(() => {
94 + let es: EventSource | null = null;
95 + let poll: ReturnType<typeof setInterval> | null = null;
96 + let cancelled = false;
97 + const qs = new URLSearchParams();
98 + if (latest.current) qs.set('since', latest.current);
99 + if (filters.event_type) qs.set('event_type', filters.event_type);
100 + if (filters.min_importance !== undefined) qs.set('min_importance', String(filters.min_importance));
101 + const startPolling = () => {
102 + if (poll) return;
103 + poll = setInterval(async () => {
104 + try {
105 + const res = await clientApi.live({ since: latest.current ?? undefined, limit: 50, event_type: filters.event_type });
106 + const items = Array.isArray(res) ? res : res.items;
107 + if (!cancelled) {
108 + setConnected(true);
109 + ingest(items ?? []);
110 + }
111 + } catch {
112 + if (!cancelled) setConnected(false);
113 + }
114 + }, 8000);
115 + };
116 + // Watchdog: a proxy that gzips text/event-stream buffers frames indefinitely (the API must send `no-transform`);
117 + // if the socket is "open" but silent for 45 s, poll as well so the feed keeps moving.
118 + let lastFrame = Date.now();
119 + const watchdog = setInterval(() => {
120 + if (Date.now() - lastFrame > 45_000) startPolling();
121 + }, 15_000);
122 + if (typeof EventSource !== 'undefined') {
123 + try {
124 + es = new EventSource(`/api/v1/live/stream${qs.toString() ? `?${qs}` : ''}`);
125 + es.addEventListener('open', () => {
126 + setConnected(true);
127 + setUpdatedAt(Date.now());
128 + });
129 + es.addEventListener('event', (m) => {
130 + lastFrame = Date.now();
131 + try {
132 + const e = JSON.parse((m as MessageEvent).data) as Event;
133 + ingest([e]);
134 + } catch {
135 + /* malformed frame */
136 + }
137 + });
138 + es.addEventListener('heartbeat', () => {
139 + lastFrame = Date.now();
140 + setUpdatedAt(Date.now());
141 + });
142 + es.onerror = () => {
143 + setConnected(false);
144 + // EventSource retries by itself; also start a low-frequency poll so the feed keeps moving behind proxies.
145 + startPolling();
146 + };
147 + } catch {
148 + startPolling();
149 + }
150 + } else startPolling();
151 + return () => {
152 + cancelled = true;
153 + es?.close();
154 + clearInterval(watchdog);
155 + if (poll) clearInterval(poll);
156 + };
157 + }, [filters, ingest]);
158 +
159 + const release = () => {
160 + if (!buffer.length) return;
161 + setEvents((prev) => [...buffer, ...prev].slice(0, MAX_ROWS));
162 + setFresh((s) => {
163 + const n = new Set(s);
164 + for (const e of buffer) n.add(e.id);
165 + return n;
166 + });
167 + const ids = buffer.map((e) => e.id);
168 + setTimeout(() => setFresh((s) => {
169 + const n = new Set(s);
170 + for (const id of ids) n.delete(id);
171 + return n;
172 + }), 2000);
173 + setBuffer([]);
174 + };
175 + useEffect(() => {
176 + if (!holding && buffer.length) release();
177 + // eslint-disable-next-line react-hooks/exhaustive-deps
178 + }, [holding]);
179 +
180 + // keyboard j/k/Enter
181 + useEffect(() => {
182 + const onKey = (e: KeyboardEvent) => {
183 + const t = e.target as HTMLElement | null;
184 + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
185 + if (document.querySelector('[role="dialog"]')) return;
186 + if (e.key === 'j' || e.key === 'k') {
187 + e.preventDefault();
188 + setSelected((s) => {
189 + const n = e.key === 'j' ? Math.min(events.length - 1, s + 1) : Math.max(0, s - 1);
190 + listRef.current?.querySelector<HTMLElement>(`[data-index="${n}"]`)?.scrollIntoView({ block: 'nearest' });
191 + return n;
192 + });
193 + } else if (e.key === 'Enter' && selected >= 0 && events[selected]) {
194 + e.preventDefault();
195 + open(events[selected] as Event);
196 + }
197 + };
198 + window.addEventListener('keydown', onKey);
199 + return () => window.removeEventListener('keydown', onKey);
200 + }, [events, selected, open]);
201 +
202 + const replay = async () => {
203 + const since = new Date(Date.now() - 3600_000).toISOString();
204 + try {
205 + const res = await clientApi.live({ since, limit: 100, event_type: filters.event_type });
206 + const items = Array.isArray(res) ? res : res.items;
207 + ingest(items ?? []);
208 + } catch {
209 + /* ignore */
210 + }
211 + };
212 +
213 + return (
214 + <div className={cn('relative', className)} data-live-feed onMouseEnter={() => setHovering(true)} onMouseLeave={() => setHovering(false)}>
215 + {showControls && (
216 + <div className="mb-2 flex flex-wrap items-center gap-2">
217 + <LiveStatus updatedAt={updatedAt} connected={connected && !holding} />
218 + <span className="text-xs text-ink-3">{holding ? (paused ? 'paused' : 'paused while hovering') : ''}</span>
219 + <div className="ml-auto flex items-center gap-1">
220 + <button type="button" onClick={replay} className="btn btn-sm" title="Replay events of the last hour">
221 + <RotateCcw className="size-3.5" aria-hidden /> Replay 1 h
222 + </button>
223 + <button type="button" onClick={() => setPaused((p) => !p)} className="btn btn-sm" aria-pressed={paused} data-pause>
224 + {paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />}
225 + {paused ? 'Resume' : 'Pause'}
226 + </button>
227 + </div>
228 + </div>
229 + )}
230 + {buffer.length > 0 && (
231 + <div className={cn('z-20 flex justify-center', stickyPill ? 'sticky top-[calc(var(--header-h)+8px)]' : '')}>
232 + <button type="button" onClick={() => { setPaused(false); release(); }} className="tnum rounded-full bg-accent px-3 py-1 text-xs font-medium text-accent-ink shadow-md" data-new-pill>
233 + {buffer.length} new {buffer.length === 1 ? 'event' : 'events'} — show
234 + </button>
235 + </div>
236 + )}
237 + {events.length === 0 ? (
238 + <p className="border border-dashed border-rule-strong px-4 py-10 text-center text-sm text-ink-3">No monitored evidence matches these filters yet. The feed stays connected — new events will appear here.</p>
239 + ) : (
240 + <ul ref={listRef} className="border-t border-rule" aria-live="polite" aria-relevant="additions">
241 + {events.slice(0, compact ? limit : MAX_ROWS).map((e, i) => (
242 + <li key={e.id} data-index={i} className="list-none">
243 + <ul>
244 + <EventRow event={e} fresh={fresh.has(e.id)} selected={i === selected} variant="feed" />
245 + </ul>
246 + </li>
247 + ))}
248 + </ul>
249 + )}
250 + {!compact && <p className="mt-2 text-[11px] text-ink-3">Keyboard: j / k to move · Enter to open evidence · hover pauses the stream.</p>}
251 + </div>
252 + );
253 +}
added apps/web/src/components/rankings/rankings-table.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import Link from 'next/link';
2 +import { CompanyTable } from '@/components/company/company-table';
3 +import { cn } from '@/lib/cn';
4 +import { fmtInt, fmtPctSigned, fmtScore, fmtSigned } from '@/lib/format';
5 +import { RANKING_KINDS, RANKING_WINDOWS, routes } from '@/lib/site';
6 +import type { Rankings } from '@/lib/types';
7 +
8 +export function RankingTabs({ kind, window, country, industry }: { kind: string; window: string; country?: string; industry?: string }) {
9 + const href = (k: string, w: string) => `${routes.rankings(k, w)}${country ? `&country=${country}` : ''}${industry ? `&industry=${industry}` : ''}`;
10 + return (
11 + <div className="space-y-2">
12 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0" role="tablist" aria-label="Ranking">
13 + {RANKING_KINDS.map((k) => (
14 + <Link key={k.id} href={href(k.id, window)} className="chip-btn" data-on={k.id === kind} role="tab" aria-selected={k.id === kind}>
15 + {k.label}
16 + </Link>
17 + ))}
18 + </div>
19 + <div className="flex flex-wrap items-center gap-1" role="radiogroup" aria-label="Window">
20 + {RANKING_WINDOWS.map((w) => (
21 + <Link key={w} href={href(kind, w)} className={cn('mono flex h-8 items-center px-2.5 text-xs', w === window ? 'bg-ink text-canvas' : 'border border-rule text-ink-2 hover:text-ink')} role="radio" aria-checked={w === window}>
22 + {w}
23 + </Link>
24 + ))}
25 + </div>
26 + </div>
27 + );
28 +}
29 +
30 +export function RankingsTable({ data }: { data: Rankings }) {
31 + const k = RANKING_KINDS.find((x) => x.id === data.kind) ?? RANKING_KINDS[0]!;
32 + const formatValue = (c: { value?: number }) => (k.unit === 'pct' ? fmtPctSigned(c.value) : k.unit === 'count' ? fmtInt(c.value) : fmtScore(c.value));
33 + const formatDelta = (c: { delta?: number | null }) => (c.delta === null || c.delta === undefined ? null : k.unit === 'pct' ? fmtSigned(c.delta, 1) + ' pt' : fmtSigned(c.delta, 1));
34 + return <CompanyTable items={data.items} rank valueLabel={k.short} formatValue={formatValue} formatDelta={formatDelta} />;
35 +}
added apps/web/src/components/ui/badges.tsx +163 −0
@@ -0,0 +1,163 @@
1 +import type { ReactNode } from 'react';
2 +import { cn } from '@/lib/cn';
3 +import { eventStyle, subtypeLabel } from '@/lib/event-styles';
4 +import { importanceSteps, significanceBand } from '@/lib/format';
5 +import { CONFIDENCE_LABELS, SENSOR_TIER_LABELS } from '@/lib/site';
6 +
7 +const base = 'inline-flex items-center gap-1 whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide';
8 +
9 +/** Event type chip with its fixed hue; `sub` appends the careful subtype label. */
10 +export function EventTypeBadge({ type, subtype, className, small = false }: { type: string; subtype?: string | null; className?: string; small?: boolean }) {
11 + const s = eventStyle(type);
12 + return (
13 + <span className={cn(base, 'uppercase', small && 'px-1 text-[10px]', className)} style={{ color: s.color, background: s.soft }} data-event-type={s.key}>
14 + {s.label}
15 + {subtype && <span className="normal-case tracking-normal opacity-80">· {subtypeLabel(subtype)}</span>}
16 + </span>
17 + );
18 +}
19 +
20 +const CONF: Record<string, string> = {
21 + VERIFIED: 'text-positive bg-positive-soft',
22 + HIGH_CONFIDENCE: 'text-positive bg-positive-soft',
23 + LIKELY: 'text-accent bg-accent-soft',
24 + INFERRED: 'text-warning bg-warning-soft',
25 + LOW_CONFIDENCE: 'text-danger bg-danger-soft',
26 +};
27 +/** Confidence label chip (spec §50). Renders nothing when missing — never invents a status. */
28 +export function ConfidenceBadge({ label, value, className, withValue = false }: { label: string | null | undefined; value?: number | null; className?: string; withValue?: boolean }) {
29 + if (!label) return null;
30 + const k = label.toUpperCase();
31 + return (
32 + <span className={cn(base, CONF[k] ?? 'text-ink-2 bg-surface-2', className)} title={value !== null && value !== undefined ? `confidence ${(value * 100).toFixed(0)} %` : undefined}>
33 + {CONFIDENCE_LABELS[k] ?? label}
34 + {withValue && value !== null && value !== undefined && <span className="tnum opacity-75">{Math.round(value * 100)} %</span>}
35 + </span>
36 + );
37 +}
38 +
39 +/** Importance as a stepped meter (▮▮▮▯). */
40 +export function ImportanceMeter({ importance, className }: { importance: number | null | undefined; className?: string }) {
41 + const n = importanceSteps(importance);
42 + const pct = importance === null || importance === undefined ? null : Math.round((importance > 1 ? importance : importance * 100));
43 + return (
44 + <span className={cn('inline-flex items-center gap-[2px]', className)} title={pct === null ? 'importance unknown' : `importance ${pct} / 100`} aria-label={pct === null ? 'importance unknown' : `Importance ${pct} of 100`}>
45 + {[1, 2, 3].map((i) => (
46 + <span key={i} className={cn('block h-2.5 w-[3px] rounded-[1px]', i <= n ? (n === 3 ? 'bg-warning' : 'bg-ink-2') : 'bg-rule-strong')} />
47 + ))}
48 + </span>
49 + );
50 +}
51 +
52 +const SIG: Record<string, string> = { noise: 'text-ink-3 bg-surface-2', minor: 'text-ink-2 bg-surface-2', meaningful: 'text-accent bg-accent-soft', major: 'text-warning bg-warning-soft', critical: 'text-danger bg-danger-soft' };
53 +export function SignificanceBadge({ value, className }: { value: number | null | undefined; className?: string }) {
54 + const band = significanceBand(value);
55 + if (!band) return null;
56 + return (
57 + <span className={cn(base, SIG[band], className)}>
58 + {band} <span className="tnum opacity-75">{(value as number).toFixed(2)}</span>
59 + </span>
60 + );
61 +}
62 +
63 +/** Sensor tier A–E (crawl cadence). */
64 +export function SensorTierBadge({ tier, className, withLabel = false }: { tier: string | null | undefined; className?: string; withLabel?: boolean }) {
65 + if (!tier) return null;
66 + const t = tier.toLowerCase();
67 + return (
68 + <span className={cn(base, 'mono', className)} style={{ color: `var(--tier-${t})`, background: `color-mix(in srgb, var(--tier-${t}) 12%, transparent)` }} title={`Tier ${tier} · ${SENSOR_TIER_LABELS[tier] ?? ''}`}>
69 + {tier}
70 + {withLabel && <span className="font-sans tracking-normal">· {SENSOR_TIER_LABELS[tier]}</span>}
71 + </span>
72 + );
73 +}
74 +
75 +/** Company tier 1–4 (importance band). */
76 +export function CompanyTierBadge({ tier, className }: { tier: number | null | undefined; className?: string }) {
77 + if (!tier) return null;
78 + return (
79 + <span className={cn(base, 'mono bg-surface-2 text-ink-2', className)} title={`Company tier ${tier} (importance band, affects crawl priority only)`}>
80 + T{tier}
81 + </span>
82 + );
83 +}
84 +
85 +const STATUS: Record<string, string> = {
86 + active: 'text-positive bg-positive-soft',
87 + ok: 'text-positive bg-positive-soft',
88 + healthy: 'text-positive bg-positive-soft',
89 + open: 'text-positive bg-positive-soft',
90 + listed: 'text-positive bg-positive-soft',
91 + current: 'text-positive bg-positive-soft',
92 + pending: 'text-accent bg-accent-soft',
93 + running: 'text-accent bg-accent-soft',
94 + review: 'text-warning bg-warning-soft',
95 + paused: 'text-warning bg-warning-soft',
96 + stale: 'text-warning bg-warning-soft',
97 + possibly_inactive: 'text-warning bg-warning-soft',
98 + failing: 'text-danger bg-danger-soft',
99 + failed: 'text-danger bg-danger-soft',
100 + dead: 'text-danger bg-danger-soft',
101 + blocked: 'text-danger bg-danger-soft',
102 + website_unavailable: 'text-danger bg-danger-soft',
103 + retracted: 'text-danger bg-danger-soft',
104 + retired: 'text-ink-3 bg-surface-2',
105 + duplicate: 'text-ink-3 bg-surface-2',
106 + no_longer_listed: 'text-ink-3 bg-surface-2',
107 + removed: 'text-ink-3 bg-surface-2',
108 + superseded: 'text-ink-3 bg-surface-2',
109 + done: 'text-ink-2 bg-surface-2',
110 +};
111 +const STATUS_LABEL: Record<string, string> = { no_longer_listed: 'no longer listed', removed: 'no longer listed', possibly_inactive: 'possibly inactive', website_unavailable: 'website unavailable' };
112 +export function StatusBadge({ status, className }: { status: string | null | undefined; className?: string }) {
113 + if (!status) return null;
114 + const k = status.toLowerCase();
115 + return <span className={cn(base, STATUS[k] ?? 'text-ink-2 bg-surface-2', className)}>{STATUS_LABEL[k] ?? k.replace(/_/g, ' ')}</span>;
116 +}
117 +
118 +/** ISO country chip (no flag emoji — consistent across platforms). */
119 +export function CountryChip({ code, name, className, link = true }: { code: string | null | undefined; name?: string | null; className?: string; link?: boolean }) {
120 + if (!code) return null;
121 + const inner = (
122 + <span className={cn(base, 'mono bg-surface-2 text-ink-2', className)} title={name ?? undefined}>
123 + {code.toUpperCase()}
124 + </span>
125 + );
126 + if (!link) return inner;
127 + return (
128 + <a href={`/country/${code.toLowerCase()}`} className="hover:opacity-80" aria-label={`Country ${name ?? code}`}>
129 + {inner}
130 + </a>
131 + );
132 +}
133 +
134 +export function Chip({ children, className, tone = 'neutral', title }: { children: ReactNode; className?: string; tone?: 'neutral' | 'accent' | 'positive' | 'warning' | 'danger' | 'outline'; title?: string }) {
135 + return (
136 + <span
137 + title={title}
138 + className={cn(
139 + base,
140 + tone === 'neutral' && 'bg-surface-2 text-ink-2',
141 + tone === 'accent' && 'bg-accent-soft text-accent',
142 + tone === 'positive' && 'bg-positive-soft text-positive',
143 + tone === 'warning' && 'bg-warning-soft text-warning',
144 + tone === 'danger' && 'bg-danger-soft text-danger',
145 + tone === 'outline' && 'border border-rule text-ink-3',
146 + className,
147 + )}
148 + >
149 + {children}
150 + </span>
151 + );
152 +}
153 +
154 +export function OriginBadge({ origin, model, prompt }: { origin: string | null | undefined; model?: string | null; prompt?: string | null }) {
155 + if (!origin) return null;
156 + const llm = origin === 'llm' || origin === 'hybrid';
157 + return (
158 + <span className={cn(base, llm ? 'bg-warning-soft text-warning' : origin === 'backfill' ? 'bg-surface-2 text-ink-3' : 'bg-surface-2 text-ink-2')} title={llm && model ? `${model}${prompt ? ` · prompt ${prompt}` : ''}` : undefined}>
159 + {origin}
160 + {llm && model && <span className="mono font-normal opacity-80">· {model}</span>}
161 + </span>
162 + );
163 +}
added apps/web/src/components/ui/key-value.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import type { ReactNode } from 'react';
2 +import { cn } from '@/lib/cn';
3 +
4 +export function KV({ children, className }: { children: ReactNode; className?: string }) {
5 + return <dl className={cn('kv', className)}>{children}</dl>;
6 +}
7 +export function Row({ k, children, className }: { k: ReactNode; children: ReactNode; className?: string }) {
8 + return (
9 + <div className={className}>
10 + <dt>{k}</dt>
11 + <dd className="text-ink">{children}</dd>
12 + </div>
13 + );
14 +}
added apps/web/src/components/ui/live.tsx +48 −0
@@ -0,0 +1,48 @@
1 +'use client';
2 +import { useEffect, useState } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { DASH, fmtAgo, fmtDateTime } from '@/lib/format';
5 +
6 +/** Green live dot with optional pulse ring. */
7 +export function Dot({ pulse = false, className, tone = 'live' }: { pulse?: boolean; className?: string; tone?: 'live' | 'warning' | 'danger' | 'muted' }) {
8 + return <span className={cn('dot', pulse && 'pulse', tone === 'warning' && 'bg-warning', tone === 'danger' && 'bg-danger', tone === 'muted' && 'bg-ink-3', className)} aria-hidden />;
9 +}
10 +
11 +/**
12 + * "17 sec ago" that ticks every second for the first minutes, then every 30 s. Server renders the absolute UTC time
13 + * (identical on both sides); the relative label is applied after mount (`suppressHydrationWarning`).
14 + */
15 +export function LiveAgo({ at, prefix = '', className, absoluteFallback = true, tick = 1000 }: { at: string | null | undefined; prefix?: string; className?: string; absoluteFallback?: boolean; tick?: number }) {
16 + const [now, setNow] = useState<number | null>(null);
17 + useEffect(() => {
18 + setNow(Date.now());
19 + const t = setInterval(() => setNow(Date.now()), tick);
20 + return () => clearInterval(t);
21 + }, [tick]);
22 + const label = now === null ? (absoluteFallback ? fmtDateTime(at) : DASH) : fmtAgo(at, now);
23 + return (
24 + <time dateTime={at ?? undefined} title={at ? fmtDateTime(at) : undefined} className={cn('tnum', className)} suppressHydrationWarning>
25 + {prefix}
26 + {label}
27 + </time>
28 + );
29 +}
30 +
31 +/** "Live · updated 12 s ago" status line for panels fed by SSE/polling. */
32 +export function LiveStatus({ updatedAt, connected = true, className }: { updatedAt: number | string | null; connected?: boolean; className?: string }) {
33 + const [now, setNow] = useState<number | null>(null);
34 + useEffect(() => {
35 + setNow(Date.now());
36 + const t = setInterval(() => setNow(Date.now()), 1000);
37 + return () => clearInterval(t);
38 + }, []);
39 + const ts = typeof updatedAt === 'number' ? updatedAt : updatedAt ? new Date(updatedAt).getTime() : null;
40 + const s = ts && now ? Math.max(0, Math.round((now - ts) / 1000)) : null;
41 + return (
42 + <span className={cn('inline-flex items-center gap-1.5 text-xs text-ink-3', className)} suppressHydrationWarning>
43 + <Dot pulse={connected} tone={connected ? 'live' : 'muted'} />
44 + <span className={cn('font-medium', connected ? 'text-positive' : 'text-ink-3')}>{connected ? 'Live' : 'Paused'}</span>
45 + {s !== null && <span className="tnum">· updated {s < 1 ? 'now' : `${s} s ago`}</span>}
46 + </span>
47 + );
48 +}
added apps/web/src/components/ui/pagination.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/cn';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +/** URL-driven pagination (server component). `makeHref(page)` builds the link preserving current filters. */
6 +export function Pagination({ total, page, pages, perPage, makeHref, className }: { total: number; page: number; pages: number; perPage: number; makeHref: (page: number) => string; className?: string }) {
7 + if (pages <= 1) return <p className={cn('tnum text-xs text-ink-3', className)}>{fmtInt(total)} results</p>;
8 + const from = (page - 1) * perPage + 1;
9 + const to = Math.min(total, page * perPage);
10 + const btn = 'inline-flex h-10 min-w-10 items-center justify-center border border-rule px-3 text-sm text-ink-2 hover:bg-surface-2 hover:text-ink';
11 + return (
12 + <nav className={cn('flex flex-wrap items-center justify-between gap-3', className)} aria-label="Pagination">
13 + <p className="tnum text-xs text-ink-3">
14 + {fmtInt(from)}–{fmtInt(to)} of {fmtInt(total)}
15 + </p>
16 + <div className="flex items-center gap-1.5">
17 + {page > 1 ? (
18 + <Link href={makeHref(page - 1)} className={btn} rel="prev">
19 + ‹ Prev
20 + </Link>
21 + ) : (
22 + <span className={cn(btn, 'opacity-40')}>‹ Prev</span>
23 + )}
24 + <span className="mono px-2 text-xs text-ink-3">
25 + {page} / {fmtInt(pages)}
26 + </span>
27 + {page < pages ? (
28 + <Link href={makeHref(page + 1)} className={btn} rel="next">
29 + Next ›
30 + </Link>
31 + ) : (
32 + <span className={cn(btn, 'opacity-40')}>Next ›</span>
33 + )}
34 + </div>
35 + </nav>
36 + );
37 +}
38 +
39 +/** Build a query-string href preserving existing params (page reset unless patched). */
40 +export function withParams(base: string, current: Record<string, string | undefined>, patch: Record<string, string | number | undefined | null>): string {
41 + const p = new URLSearchParams();
42 + for (const [k, v] of Object.entries(current)) if (v !== undefined && v !== '') p.set(k, v);
43 + for (const [k, v] of Object.entries(patch)) {
44 + if (v === undefined || v === null || v === '') p.delete(k);
45 + else p.set(k, String(v));
46 + }
47 + if (!('page' in patch)) p.delete('page');
48 + const s = p.toString();
49 + return s ? `${base}?${s}` : base;
50 +}
added apps/web/src/components/ui/section.tsx +115 −0
@@ -0,0 +1,115 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +
5 +/** Page-width wrapper. Pages render inside <main> without padding: wrap content in Container (1280) or wide (1600). */
6 +export function Container({ children, className, wide = false }: { children: ReactNode; className?: string; wide?: boolean }) {
7 + return <div className={cn('container-x mx-auto w-full', wide ? 'max-w-[1600px]' : 'max-w-[1280px]', className)}>{children}</div>;
8 +}
9 +
10 +/** Section with eyebrow + title + optional action link. Spatial composition with a hairline — not a card. */
11 +export function Section({
12 + eyebrow,
13 + title,
14 + lede,
15 + action,
16 + children,
17 + className,
18 + id,
19 + hairline = true,
20 + aside,
21 +}: {
22 + eyebrow?: string;
23 + title?: ReactNode;
24 + lede?: ReactNode;
25 + action?: { href: string; label: string };
26 + children: ReactNode;
27 + className?: string;
28 + id?: string;
29 + hairline?: boolean;
30 + aside?: ReactNode;
31 +}) {
32 + return (
33 + <section id={id} className={cn('section-y scroll-mt-20', hairline && 'hairline', className)}>
34 + {(eyebrow || title) && (
35 + <div className="mb-4 flex items-end justify-between gap-4 md:mb-5">
36 + <div className="min-w-0">
37 + {eyebrow && <p className="eyebrow">{eyebrow}</p>}
38 + {title && <h2 className="mt-1 text-lg font-semibold tracking-tight md:text-xl">{title}</h2>}
39 + {lede && <p className="mt-1 max-w-2xl text-sm text-ink-2">{lede}</p>}
40 + </div>
41 + {aside}
42 + {action && (
43 + <Link href={action.href} className="link shrink-0 py-1 text-sm">
44 + {action.label} →
45 + </Link>
46 + )}
47 + </div>
48 + )}
49 + {children}
50 + </section>
51 + );
52 +}
53 +
54 +export function PageHeader({ eyebrow, title, lede, children, className, aside }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; children?: ReactNode; className?: string; aside?: ReactNode }) {
55 + return (
56 + <div className={cn('pb-5 pt-6 md:pb-7 md:pt-10', className)}>
57 + <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
58 + <div className="min-w-0">
59 + {eyebrow && <div className="eyebrow flex flex-wrap items-center gap-2">{eyebrow}</div>}
60 + <h1 className="display mt-2 text-[26px] md:text-[38px]">{title}</h1>
61 + {lede && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">{lede}</p>}
62 + </div>
63 + {aside && <div className="shrink-0">{aside}</div>}
64 + </div>
65 + {children}
66 + </div>
67 + );
68 +}
69 +
70 +/** Stat tile: big tabular number + label + optional delta/hint. Composes in a hairline grid, never in a card. */
71 +export function Stat({ label, value, hint, delta, className, accent = false, href, size = 'md' }: { label: ReactNode; value: ReactNode; hint?: ReactNode; delta?: { value: string; tone?: 'positive' | 'negative' | 'neutral' }; className?: string; accent?: boolean; href?: string; size?: 'sm' | 'md' | 'lg' }) {
72 + const body = (
73 + <>
74 + <p className="eyebrow">{label}</p>
75 + <p className={cn('tnum mt-1 font-semibold leading-none tracking-tight', size === 'lg' && 'text-[30px] md:text-[38px]', size === 'md' && 'text-[24px] md:text-[30px]', size === 'sm' && 'text-[20px] md:text-[22px]', accent && 'text-accent')}>{value}</p>
76 + {(hint || delta) && (
77 + <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-ink-3">
78 + {delta && <span className={cn('tnum font-medium', delta.tone === 'positive' && 'text-positive', delta.tone === 'negative' && 'text-danger', (!delta.tone || delta.tone === 'neutral') && 'text-ink-2')}>{delta.value}</span>}
79 + {hint}
80 + </p>
81 + )}
82 + </>
83 + );
84 + if (href)
85 + return (
86 + <Link href={href} className={cn('block min-w-0 py-3 hover:text-accent md:py-4', className)}>
87 + {body}
88 + </Link>
89 + );
90 + return <div className={cn('min-w-0 py-3 md:py-4', className)}>{body}</div>;
91 +}
92 +
93 +export function StatGrid({ children, cols = 4, className }: { children: ReactNode; cols?: 2 | 3 | 4 | 5 | 6; className?: string }) {
94 + const desktop = { 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4', 5: 'md:grid-cols-5', 6: 'md:grid-cols-3 lg:grid-cols-6' }[cols];
95 + return <div className={cn('grid grid-cols-2 gap-x-6 border-y border-rule [&>*]:border-b [&>*]:border-rule md:[&>*]:border-b-0', desktop, className)}>{children}</div>;
96 +}
97 +
98 +/** Small honest note. */
99 +export function Note({ children, className }: { children: ReactNode; className?: string }) {
100 + return <p className={cn('text-xs leading-relaxed text-ink-3', className)}>{children}</p>;
101 +}
102 +
103 +/** Honest empty state — spec §165 wording. */
104 +export function Empty({ title = 'No monitored evidence available yet.', children, className, compact = false }: { title?: string; children?: ReactNode; className?: string; compact?: boolean }) {
105 + return (
106 + <div className={cn('border border-dashed border-rule-strong text-center text-ink-3', compact ? 'px-3 py-4 text-xs' : 'px-5 py-10 text-sm', className)} role="status">
107 + <p className="text-ink-2">{title}</p>
108 + {children && <div className="mt-1 text-xs">{children}</div>}
109 + </div>
110 + );
111 +}
112 +
113 +export function Unavailable({ what = 'This panel', className, compact = false }: { what?: string; className?: string; compact?: boolean }) {
114 + return <Empty title={`${what} is temporarily unavailable.`} className={className} compact={compact}>The dataset itself is intact — the API did not answer in time.</Empty>;
115 +}
added apps/web/src/components/ui/sheet.tsx +105 −0
@@ -0,0 +1,105 @@
1 +'use client';
2 +import { X } from 'lucide-react';
3 +import { type ReactNode, useEffect, useRef } from 'react';
4 +import { cn } from '@/lib/cn';
5 +
6 +/**
7 + * Sheet / drawer primitive shared by the event drawer, filter sheets and the mobile "More" menu.
8 + * `side="auto"` (default): right-side panel ≥ lg, bottom sheet below. Focus trap, Esc, backdrop click, body scroll lock.
9 + */
10 +export function Sheet({
11 + open,
12 + onClose,
13 + title,
14 + eyebrow,
15 + children,
16 + side = 'auto',
17 + width = 'lg:w-[30rem]',
18 + className,
19 + footer,
20 + id,
21 + headerExtra,
22 +}: {
23 + open: boolean;
24 + onClose: () => void;
25 + title?: ReactNode;
26 + eyebrow?: ReactNode;
27 + children: ReactNode;
28 + side?: 'auto' | 'right' | 'bottom';
29 + width?: string;
30 + className?: string;
31 + footer?: ReactNode;
32 + id?: string;
33 + headerExtra?: ReactNode;
34 +}) {
35 + const panel = useRef<HTMLDivElement>(null);
36 + const closeBtn = useRef<HTMLButtonElement>(null);
37 + const restore = useRef<HTMLElement | null>(null);
38 +
39 + useEffect(() => {
40 + if (!open) return;
41 + restore.current = document.activeElement as HTMLElement | null;
42 + const prevOverflow = document.body.style.overflow;
43 + document.body.style.overflow = 'hidden';
44 + const t = setTimeout(() => closeBtn.current?.focus(), 20);
45 + const onKey = (e: KeyboardEvent) => {
46 + if (e.key === 'Escape') {
47 + e.stopPropagation();
48 + onClose();
49 + } else if (e.key === 'Tab' && panel.current) {
50 + const nodes = panel.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');
51 + if (!nodes.length) return;
52 + const first = nodes[0] as HTMLElement;
53 + const last = nodes[nodes.length - 1] as HTMLElement;
54 + if (e.shiftKey && document.activeElement === first) {
55 + e.preventDefault();
56 + last.focus();
57 + } else if (!e.shiftKey && document.activeElement === last) {
58 + e.preventDefault();
59 + first.focus();
60 + }
61 + }
62 + };
63 + document.addEventListener('keydown', onKey);
64 + return () => {
65 + clearTimeout(t);
66 + document.removeEventListener('keydown', onKey);
67 + document.body.style.overflow = prevOverflow;
68 + restore.current?.focus?.();
69 + };
70 + }, [open, onClose]);
71 +
72 + if (!open) return null;
73 + const right = side === 'right';
74 + return (
75 + <div className="fixed inset-0 z-[90] flex bg-black/45 backdrop-blur-[1px]" onClick={onClose} role="presentation">
76 + <div
77 + ref={panel}
78 + id={id}
79 + role="dialog"
80 + aria-modal="true"
81 + onClick={(e) => e.stopPropagation()}
82 + className={cn(
83 + 'sheet-enter panel flex flex-col overflow-hidden shadow-2xl',
84 + !right && 'absolute inset-x-0 bottom-0 max-h-[88dvh] rounded-b-none rounded-t-lg',
85 + side === 'auto' && `lg:inset-y-0 lg:right-0 lg:left-auto lg:h-full lg:max-h-none lg:rounded-none lg:border-y-0 lg:border-r-0 ${width} lg:max-w-[92vw]`,
86 + right && `absolute inset-y-0 right-0 h-full max-w-[92vw] rounded-none border-y-0 border-r-0 ${width}`,
87 + className,
88 + )}
89 + >
90 + <div className="flex items-start gap-3 border-b border-rule px-4 py-3">
91 + <div className="min-w-0 flex-1">
92 + {eyebrow && <p className="eyebrow">{eyebrow}</p>}
93 + {title && <h2 className="mt-0.5 text-[15px] font-semibold leading-snug text-ink">{title}</h2>}
94 + </div>
95 + {headerExtra}
96 + <button ref={closeBtn} type="button" onClick={onClose} className="-mr-1 flex size-11 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close">
97 + <X className="size-5" aria-hidden />
98 + </button>
99 + </div>
100 + <div className="scrollbar-thin flex-1 overflow-y-auto px-4 py-3">{children}</div>
101 + {footer && <div className="safe-bottom border-t border-rule px-4 py-3">{footer}</div>}
102 + </div>
103 + </div>
104 + );
105 +}
added apps/web/src/components/ui/skeleton.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import { cn } from '@/lib/cn';
2 +
3 +export function Skeleton({ className }: { className?: string }) {
4 + return <div className={cn('skeleton', className)} aria-hidden />;
5 +}
6 +
7 +export function SkeletonRows({ rows = 6, className }: { rows?: number; className?: string }) {
8 + return (
9 + <div className={cn('space-y-2', className)} aria-busy="true" aria-live="polite">
10 + {Array.from({ length: rows }).map((_, i) => (
11 + <div key={i} className="flex items-center gap-3 py-2">
12 + <Skeleton className="h-3 w-14" />
13 + <Skeleton className="h-3 flex-1" />
14 + <Skeleton className="h-3 w-20" />
15 + </div>
16 + ))}
17 + </div>
18 + );
19 +}
20 +
21 +export function SkeletonTiles({ n = 4, className }: { n?: number; className?: string }) {
22 + return (
23 + <div className={cn('grid grid-cols-2 gap-6 md:grid-cols-4', className)} aria-busy="true">
24 + {Array.from({ length: n }).map((_, i) => (
25 + <div key={i} className="space-y-2 py-3">
26 + <Skeleton className="h-2.5 w-20" />
27 + <Skeleton className="h-7 w-24" />
28 + <Skeleton className="h-2.5 w-16" />
29 + </div>
30 + ))}
31 + </div>
32 + );
33 +}
added apps/web/src/components/ui/tabs.tsx +104 −0
@@ -0,0 +1,104 @@
1 +'use client';
2 +import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3 +import { createContext, type ReactNode, useContext, useEffect, useId, useRef } from 'react';
4 +import { cn } from '@/lib/cn';
5 +
6 +export type TabDef = { id: string; label: string; count?: number | null; hidden?: boolean };
7 +
8 +const Ctx = createContext<{ active: string; uid: string }>({ active: '', uid: '' });
9 +
10 +/**
11 + * URL-driven tabs (`?tab=<id>`); all panels are rendered server-side, only the active one is shown. Arrow keys move.
12 + * The strip scrolls horizontally on mobile.
13 + */
14 +export function Tabs({ tabs, defaultTab, children, param = 'tab', className, sticky = false }: { tabs: TabDef[]; defaultTab?: string; children: ReactNode; param?: string; className?: string; sticky?: boolean }) {
15 + const visible = tabs.filter((t) => !t.hidden);
16 + const first = defaultTab ?? visible[0]?.id ?? '';
17 + const router = useRouter();
18 + const pathname = usePathname();
19 + const sp = useSearchParams();
20 + const stripRef = useRef<HTMLDivElement>(null);
21 + const uid = useId();
22 + const requested = sp.get(param) ?? '';
23 + const active = visible.some((t) => t.id === requested) ? requested : first;
24 +
25 + const select = (id: string) => {
26 + const next = new URLSearchParams(sp.toString());
27 + if (id === first) next.delete(param);
28 + else next.set(param, id);
29 + const q = next.toString();
30 + router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
31 + };
32 +
33 + useEffect(() => {
34 + stripRef.current?.querySelector<HTMLElement>(`[data-tab="${active}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
35 + }, [active]);
36 +
37 + const onKey = (e: React.KeyboardEvent) => {
38 + const idx = visible.findIndex((t) => t.id === active);
39 + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
40 + e.preventDefault();
41 + const n = visible[(idx + (e.key === 'ArrowRight' ? 1 : visible.length - 1)) % visible.length];
42 + if (n) {
43 + select(n.id);
44 + stripRef.current?.querySelector<HTMLElement>(`[data-tab="${n.id}"]`)?.focus();
45 + }
46 + }
47 + };
48 +
49 + return (
50 + <div className={className} data-active-tab={active}>
51 + <div className={cn(sticky && 'sticky top-[var(--header-h)] z-30 -mx-4 bg-canvas/95 px-4 backdrop-blur-md md:mx-0 md:px-0')}>
52 + <div ref={stripRef} role="tablist" aria-label="Sections" onKeyDown={onKey} className="no-scrollbar -mx-4 flex overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0">
53 + {visible.map((t) => {
54 + const on = t.id === active;
55 + return (
56 + <button
57 + key={t.id}
58 + type="button"
59 + role="tab"
60 + id={`${uid}-tab-${t.id}`}
61 + data-tab={t.id}
62 + aria-selected={on}
63 + aria-controls={`${uid}-panel-${t.id}`}
64 + tabIndex={on ? 0 : -1}
65 + onClick={() => select(t.id)}
66 + className={cn('-mb-px flex h-11 shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-sm transition-colors first:pl-0', on ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink')}
67 + >
68 + {t.label}
69 + {t.count !== undefined && t.count !== null && <span className={cn('tnum text-[11px]', on ? 'text-ink-2' : 'text-ink-3')}>{t.count}</span>}
70 + </button>
71 + );
72 + })}
73 + </div>
74 + </div>
75 + <Ctx.Provider value={{ active, uid }}>{children}</Ctx.Provider>
76 + </div>
77 + );
78 +}
79 +
80 +export function TabPanel({ id, children, className }: { id: string; children: ReactNode; className?: string }) {
81 + const { active, uid } = useContext(Ctx);
82 + const on = active === id;
83 + return (
84 + <div role="tabpanel" id={`${uid}-panel-${id}`} aria-labelledby={`${uid}-tab-${id}`} hidden={!on} className={cn('pt-5 md:pt-6', className)}>
85 + {children}
86 + </div>
87 + );
88 +}
89 +
90 +/** Simple segmented control (not URL-bound) for local state like windows/kinds. */
91 +export function Segmented<T extends string>({ options, value, onChange, className, size = 'md', ariaLabel }: { options: { id: T; label: string }[]; value: T; onChange: (v: T) => void; className?: string; size?: 'sm' | 'md'; ariaLabel?: string }) {
92 + return (
93 + <div role="radiogroup" aria-label={ariaLabel} className={cn('no-scrollbar inline-flex max-w-full overflow-x-auto rounded-[var(--radius)] border border-rule bg-surface p-0.5', className)}>
94 + {options.map((o) => {
95 + const on = o.id === value;
96 + return (
97 + <button key={o.id} type="button" role="radio" aria-checked={on} onClick={() => onChange(o.id)} className={cn('shrink-0 rounded-[3px] px-2.5 whitespace-nowrap transition-colors', size === 'sm' ? 'h-8 text-xs' : 'h-9 text-[13px]', on ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink')}>
98 + {o.label}
99 + </button>
100 + );
101 + })}
102 + </div>
103 + );
104 +}
added apps/web/src/lib/admin-modules.ts +13 −0
@@ -0,0 +1,13 @@
1 +/** Admin console modules (server-safe: imported by the /admin/[module] route and the client shell). */
2 +export const ADMIN_MODULES: { id: string; label: string; hint: string }[] = [
3 + { id: 'overview', label: 'Overview', hint: 'Queue, workers, rates, storage, cost' },
4 + { id: 'connectors', label: 'Connectors', hint: 'Connector control center' },
5 + { id: 'sensors', label: 'Sensors', hint: 'Filters and actions' },
6 + { id: 'companies', label: 'Companies', hint: 'Onboarding and rediscovery' },
7 + { id: 'failures', label: 'Failures', hint: 'By class, last 24 h' },
8 + { id: 'queue', label: 'Queue', hint: 'Jobs by kind and status' },
9 + { id: 'llm', label: 'LLM jobs', hint: 'Enrichment pipeline' },
10 + { id: 'reviews', label: 'Reviews', hint: 'Human review queue' },
11 + { id: 'quality', label: 'Quality', hint: 'Coverage, freshness, calibration' },
12 + { id: 'costs', label: 'Costs', hint: 'Per dimension and unit economics' },
13 +];
added apps/web/src/lib/admin.ts +37 −0
@@ -0,0 +1,37 @@
1 +'use client';
2 +/** Admin token (`X-CA-Admin-Token`) kept in localStorage; entered once on /admin. Never rendered back into the page. */
3 +import { useEffect, useState } from 'react';
4 +
5 +export const ADMIN_KEY = 'ca-admin-token';
6 +const EVENT = 'ca-admin-change';
7 +
8 +export function readAdminToken(): string | null {
9 + if (typeof window === 'undefined') return null;
10 + try {
11 + return window.localStorage.getItem(ADMIN_KEY) || null;
12 + } catch {
13 + return null;
14 + }
15 +}
16 +export function setAdminToken(t: string | null) {
17 + try {
18 + if (t) window.localStorage.setItem(ADMIN_KEY, t);
19 + else window.localStorage.removeItem(ADMIN_KEY);
20 + } catch {
21 + /* ignore */
22 + }
23 + window.dispatchEvent(new CustomEvent(EVENT));
24 +}
25 +/** `undefined` while unknown (before mount), `null` when absent. */
26 +export function useAdminToken(): string | null | undefined {
27 + const [t, setT] = useState<string | null | undefined>(undefined);
28 + useEffect(() => {
29 + const read = () => setT(readAdminToken());
30 + read();
31 + window.addEventListener(EVENT, read);
32 + return () => window.removeEventListener(EVENT, read);
33 + }, []);
34 + return t;
35 +}
36 +
37 +export { ADMIN_MODULES } from './admin-modules';
added apps/web/src/lib/api.ts +205 −0
@@ -0,0 +1,205 @@
1 +import 'server-only';
2 +import type {
3 + ActivityIndex,
4 + ChangeDetail,
5 + CompanyCard,
6 + CompanyDetail,
7 + CompanyMetrics,
8 + ComparePayload,
9 + CountryDetailRaw,
10 + CountryRow,
11 + Event,
12 + EventDetail,
13 + EventSummary,
14 + EventTypes,
15 + GlobalDaily,
16 + HistoryPayload,
17 + IndustryDetailRaw,
18 + IndustryRow,
19 + JobsPage,
20 + Location,
21 + MapBucket,
22 + Methodology,
23 + NewsItem,
24 + Page,
25 + Person,
26 + Plan,
27 + Product,
28 + Pulse,
29 + Rankings,
30 + SearchPayload,
31 + Sensor,
32 + SensorDetail,
33 + Signal,
34 + SitemapPayload,
35 + Snapshot,
36 + SnapshotDetail,
37 + SnapshotDiff,
38 + Stats,
39 + Suggestion,
40 + SystemHealth,
41 + TimelinePayload,
42 + TrendRow,
43 + Change,
44 + AskPayload,
45 +} from './types';
46 +
47 +/**
48 + * Typed fetch wrapper for the Company Atlas API (server components only — client code uses the same-origin `/api/v1/*`
49 + * rewrite through `src/lib/client-api.ts`).
50 + *
51 + * - default cache: ISR `next: { revalidate: 120 }`; live endpoints pass `revalidate: false` (no-store).
52 + * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages render an "unavailable"
53 + * state rather than crash; 404 → `notFound()` in the page/layout.
54 + * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel.
55 + */
56 +export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8371').replace(/\/$/, '');
57 +const BASE = `${API_URL}/api/v1`;
58 +
59 +export class ApiError extends Error {
60 + readonly status: number;
61 + readonly detail: string | null;
62 + readonly path: string;
63 + constructor(status: number, path: string, detail: string | null, message?: string) {
64 + super(message ?? detail ?? `API ${status} on ${path}`);
65 + this.name = 'ApiError';
66 + this.status = status;
67 + this.detail = detail;
68 + this.path = path;
69 + }
70 + get unavailable(): boolean {
71 + return this.status === 0 || this.status >= 500;
72 + }
73 + get notFound(): boolean {
74 + return this.status === 404;
75 + }
76 +}
77 +
78 +export interface FetchOptions {
79 + /** Seconds; `false` → `cache: 'no-store'`. Default 120. */
80 + revalidate?: number | false;
81 + tags?: string[];
82 +}
83 +export type Query = Record<string, string | number | boolean | null | undefined>;
84 +
85 +function qs(query?: Query): string {
86 + if (!query) return '';
87 + const p = new URLSearchParams();
88 + for (const [k, v] of Object.entries(query)) {
89 + if (v === undefined || v === null || v === '') continue;
90 + p.set(k, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
91 + }
92 + const s = p.toString();
93 + return s ? `?${s}` : '';
94 +}
95 +
96 +export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {
97 + const url = `${BASE}${path}${qs(query)}`;
98 + const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };
99 + if (opts.revalidate === false) init.cache = 'no-store';
100 + else init.next = { revalidate: opts.revalidate ?? 120, tags: opts.tags };
101 + let res: Response;
102 + try {
103 + res = await fetch(url, init);
104 + } catch (e) {
105 + throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);
106 + }
107 + if (!res.ok) {
108 + let detail: string | null = null;
109 + try {
110 + const body = (await res.json()) as { detail?: unknown };
111 + detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;
112 + } catch {
113 + /* non-JSON error body */
114 + }
115 + throw new ApiError(res.status, path, detail);
116 + }
117 + return (await res.json()) as T;
118 +}
119 +
120 +export async function safe<T>(p: Promise<T>): Promise<T | null> {
121 + try {
122 + return await p;
123 + } catch {
124 + return null;
125 + }
126 +}
127 +
128 +/** Throw `notFound()`-worthy errors up, swallow the rest as null (for detail pages: 404 must reach the layout). */
129 +export async function orNull<T>(p: Promise<T>): Promise<T | null> {
130 + try {
131 + return await p;
132 + } catch (e) {
133 + if (e instanceof ApiError && e.notFound) throw e;
134 + return null;
135 + }
136 +}
137 +
138 +const enc = encodeURIComponent;
139 +const LIVE = { revalidate: false } as const;
140 +
141 +export const api = {
142 + // platform
143 + stats: () => request<Stats>('/stats', undefined, { revalidate: 60 }),
144 + statsHistory: (days = 90) => request<{ items: GlobalDaily[] }>('/stats/history', { days }, { revalidate: 900 }),
145 + system: () => request<SystemHealth>('/system', undefined, { revalidate: 30 }),
146 + pulse: () => request<Pulse>('/pulse', undefined, { revalidate: 60 }),
147 + live: (query: Query = {}) => request<{ items: Event[] } | Event[]>('/live', { limit: 50, ...query }, LIVE),
148 +
149 + // companies
150 + companies: (query: Query) => request<Page<CompanyCard>>('/companies', query, { revalidate: 120 }),
151 + company: (slug: string) => request<CompanyDetail>(`/companies/${enc(slug)}`, undefined, { revalidate: 120 }),
152 + companyEvents: (slug: string, query: Query = {}) => request<Page<Event>>(`/companies/${enc(slug)}/events`, query, { revalidate: 120 }),
153 + companyTimeline: (slug: string, filter = 'all', limit = 200) => request<TimelinePayload>(`/companies/${enc(slug)}/timeline`, { filter, limit }, { revalidate: 120 }),
154 + companyMetrics: (slug: string, days = 90, metric?: string) => request<CompanyMetrics>(`/companies/${enc(slug)}/metrics`, { days, metric }, { revalidate: 300 }),
155 + companyJobs: (slug: string, query: Query = {}) => request<JobsPage>(`/companies/${enc(slug)}/jobs`, query, { revalidate: 120 }),
156 + companyPeople: (slug: string) => request<{ listed: Person[]; no_longer_listed: Person[] }>(`/companies/${enc(slug)}/people`, undefined, { revalidate: 300 }),
157 + companyProducts: (slug: string) => request<{ listed: Product[]; removed: Product[] }>(`/companies/${enc(slug)}/products`, undefined, { revalidate: 300 }),
158 + companyPricing: (slug: string) => request<{ current: Plan[]; history: Plan[] }>(`/companies/${enc(slug)}/pricing`, undefined, { revalidate: 300 }),
159 + companyLocations: (slug: string) => request<{ items: Location[]; countries: string[] }>(`/companies/${enc(slug)}/locations`, undefined, { revalidate: 300 }),
160 + companyNews: (slug: string, limit = 50) => request<{ items: NewsItem[] }>(`/companies/${enc(slug)}/news`, { limit }, { revalidate: 300 }),
161 + companySensors: (slug: string) => request<{ items: Sensor[] }>(`/companies/${enc(slug)}/sensors`, undefined, { revalidate: 120 }),
162 + companyHistory: (slug: string) => request<HistoryPayload>(`/companies/${enc(slug)}/history`, undefined, { revalidate: 300 }),
163 + companySimilar: (slug: string, limit = 8) => request<{ items: CompanyCard[] }>(`/companies/${enc(slug)}/similar`, { limit }, { revalidate: 600 }),
164 + compare: (slugs: string[]) => request<ComparePayload>('/companies/compare', { companies: slugs.join(',') }, { revalidate: 120 }),
165 +
166 + // provenance
167 + sensor: (id: string) => request<SensorDetail>(`/sensors/${enc(id)}`, undefined, { revalidate: 60 }),
168 + sensorSnapshots: (id: string, limit = 50) => request<{ items: Snapshot[] }>(`/sensors/${enc(id)}/snapshots`, { limit }, { revalidate: 60 }),
169 + sensorChanges: (id: string, limit = 50) => request<{ items: Change[] }>(`/sensors/${enc(id)}/changes`, { limit }, { revalidate: 60 }),
170 + snapshot: (id: string) => request<SnapshotDetail>(`/snapshots/${enc(id)}`, undefined, { revalidate: 3600 }),
171 + snapshotDiff: (id: string, other: string) => request<SnapshotDiff>(`/snapshots/${enc(id)}/diff/${enc(other)}`, undefined, { revalidate: 3600 }),
172 + change: (id: string) => request<ChangeDetail>(`/changes/${enc(id)}`, undefined, { revalidate: 600 }),
173 + event: (id: string) => request<EventDetail>(`/events/${enc(id)}`, undefined, { revalidate: 120 }),
174 +
175 + // events
176 + events: (query: Query) => request<Page<Event>>('/events', query, { revalidate: 60 }),
177 + eventTypes: () => request<EventTypes>('/events/types', undefined, { revalidate: 600 }),
178 + eventSummary: (days = 7, group = 'type') => request<EventSummary>('/events/summary', { days, group }, { revalidate: 300 }),
179 +
180 + // rankings & atlases
181 + rankings: (query: Query) => request<Rankings>('/rankings', query, { revalidate: 120 }),
182 + industries: () => request<{ items: IndustryRow[] }>('/industries', undefined, { revalidate: 300 }),
183 + industry: (slug: string) => request<IndustryDetailRaw>(`/industries/${enc(slug)}`, undefined, { revalidate: 300 }),
184 + countries: () => request<{ items: CountryRow[] }>('/countries', undefined, { revalidate: 300 }),
185 + country: (code: string) => request<CountryDetailRaw>(`/countries/${enc(code)}`, undefined, { revalidate: 300 }),
186 + signals: (query: Query = {}) => request<{ items: Signal[] }>('/signals', query, { revalidate: 120 }),
187 + trends: (window = '7d', limit = 30) => request<{ items: TrendRow[] }>('/trends', { window, limit }, { revalidate: 300 }),
188 + map: (metric = 'events_30d') => request<{ buckets: MapBucket[] }>('/map', { metric }, { revalidate: 300 }),
189 + index: () => request<ActivityIndex>('/index', undefined, { revalidate: 300 }),
190 +
191 + // search
192 + search: (q: string, query: Query = {}) => request<SearchPayload>('/search', { q, ...query }, LIVE),
193 + suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, LIVE),
194 + ask: (q: string) => request<AskPayload>('/ask', { q }, LIVE),
195 +
196 + // docs
197 + sitemap: (kind: string, page = 0) => request<SitemapPayload>('/sitemap', { kind, page }, { revalidate: 3600 }),
198 + methodology: () => request<Methodology>('/methodology', undefined, { revalidate: 3600 }),
199 +};
200 +
201 +/** `/live` may answer `{items}` or a bare array — normalise. */
202 +export function liveItems(v: { items: Event[] } | Event[] | null): Event[] {
203 + if (!v) return [];
204 + return Array.isArray(v) ? v : (v.items ?? []);
205 +}
added apps/web/src/lib/client-api.ts +118 −0
@@ -0,0 +1,118 @@
1 +'use client';
2 +/**
3 + * Browser-side fetches: same origin `/api/v1/*` (Next rewrite → FastAPI). Never import the server `api.ts` in client code.
4 + * Owner and admin calls attach their token headers (`lib/owner.ts`, `lib/admin.ts`).
5 + */
6 +import type {
7 + Alert,
8 + AlertCondition,
9 + AlertDelivery,
10 + AdminCompany,
11 + AdminConnector,
12 + AdminCosts,
13 + AdminFailure,
14 + AdminLlmJob,
15 + AdminOverview,
16 + AdminQuality,
17 + AdminQueueItem,
18 + AdminReview,
19 + AdminSensor,
20 + AskPayload,
21 + CompanyCard,
22 + Event,
23 + EventDetail,
24 + Page,
25 + SearchPayload,
26 + Suggestion,
27 + WatchlistPayload,
28 +} from './types';
29 +
30 +export class ClientApiError extends Error {
31 + readonly status: number;
32 + readonly detail: string | null;
33 + constructor(status: number, path: string, detail: string | null = null) {
34 + super(detail ?? `API ${status} on ${path}`);
35 + this.name = 'ClientApiError';
36 + this.status = status;
37 + this.detail = detail;
38 + }
39 +}
40 +
41 +type Init = { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal };
42 +
43 +async function call<T>(path: string, init: Init = {}): Promise<T> {
44 + const headers: Record<string, string> = { accept: 'application/json', ...(init.headers ?? {}) };
45 + if (init.body !== undefined) headers['content-type'] = 'application/json';
46 + const res = await fetch(`/api/v1${path}`, { method: init.method ?? 'GET', headers, body: init.body === undefined ? undefined : JSON.stringify(init.body), signal: init.signal, cache: 'no-store' });
47 + if (!res.ok) {
48 + let detail: string | null = null;
49 + try {
50 + const b = (await res.json()) as { detail?: unknown };
51 + detail = typeof b.detail === 'string' ? b.detail : null;
52 + } catch {
53 + /* ignore */
54 + }
55 + throw new ClientApiError(res.status, path, detail);
56 + }
57 + if (res.status === 204) return undefined as T;
58 + return (await res.json()) as T;
59 +}
60 +
61 +const enc = encodeURIComponent;
62 +
63 +export function buildQs(q: Record<string, string | number | boolean | null | undefined>): string {
64 + const p = new URLSearchParams();
65 + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p.set(k, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
66 + const s = p.toString();
67 + return s ? `?${s}` : '';
68 +}
69 +
70 +export const clientApi = {
71 + suggest: (q: string, signal?: AbortSignal) => call<{ items: Suggestion[] }>(`/search/suggest?q=${enc(q)}`, { signal }),
72 + search: (q: string, limit = 8, signal?: AbortSignal) => call<SearchPayload>(`/search?q=${enc(q)}&limit=${limit}`, { signal }),
73 + ask: (q: string, signal?: AbortSignal) => call<AskPayload>(`/ask?q=${enc(q)}`, { signal }),
74 + live: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => call<{ items: Event[] } | Event[]>(`/live${buildQs(q)}`, { signal }),
75 + event: (id: string, signal?: AbortSignal) => call<EventDetail>(`/events/${enc(id)}`, { signal }),
76 + companies: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => call<Page<CompanyCard>>(`/companies${buildQs(q)}`, { signal }),
77 +};
78 +
79 +/** Watchlist + alerts with the owner token. */
80 +export function ownerApi(token: string) {
81 + const h = { 'X-CA-Owner-Token': token };
82 + return {
83 + watchlist: (signal?: AbortSignal) => call<WatchlistPayload>('/watchlist', { headers: h, signal }),
84 + watch: (slug: string) => call<unknown>('/watchlist', { method: 'POST', body: { company: slug }, headers: h }),
85 + unwatch: (slug: string) => call<unknown>(`/watchlist/${enc(slug)}`, { method: 'DELETE', headers: h }),
86 + alerts: (signal?: AbortSignal) => call<{ items: Alert[] }>('/alerts', { headers: h, signal }),
87 + createAlert: (body: { name: string; company?: string; condition: AlertCondition; channel: 'web' | 'webhook'; target?: string }) => call<Alert>('/alerts', { method: 'POST', body, headers: h }),
88 + deleteAlert: (id: string) => call<unknown>(`/alerts/${enc(id)}`, { method: 'DELETE', headers: h }),
89 + deliveries: (limit = 50, signal?: AbortSignal) => call<{ items: AlertDelivery[] }>(`/alerts/deliveries?limit=${limit}`, { headers: h, signal }),
90 + };
91 +}
92 +
93 +/** Admin console with the admin token. */
94 +export function adminApi(token: string) {
95 + const h = { 'X-CA-Admin-Token': token };
96 + const g = <T>(path: string, signal?: AbortSignal) => call<T>(path, { headers: h, signal });
97 + const post = <T>(path: string, body?: unknown) => call<T>(path, { method: 'POST', body: body ?? {}, headers: h });
98 + return {
99 + overview: (signal?: AbortSignal) => g<AdminOverview>('/admin/overview', signal),
100 + connectors: (signal?: AbortSignal) => g<{ items: AdminConnector[] }>('/admin/connectors', signal),
101 + sensors: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => g<Page<AdminSensor>>(`/admin/sensors${buildQs(q)}`, signal),
102 + sensorAction: (id: string, action: string, body?: unknown) => post<unknown>(`/admin/sensors/${enc(id)}/${action}`, body),
103 + companies: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => g<Page<AdminCompany>>(`/admin/companies${buildQs(q)}`, signal),
104 + createCompany: (body: { website: string; display_name?: string; country?: string; industries?: string[] }) => post<unknown>('/admin/companies', body),
105 + rediscover: (slug: string) => post<unknown>(`/admin/companies/${enc(slug)}/rediscover`),
106 + failures: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => g<Page<AdminFailure>>(`/admin/failures${buildQs(q)}`, signal),
107 + queue: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => g<{ items: AdminQueueItem[]; counts?: Record<string, number> } | Page<AdminQueueItem>>(`/admin/queue${buildQs(q)}`, signal),
108 + requeueDead: () => post<unknown>('/admin/queue/requeue-dead'),
109 + llm: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => g<Page<AdminLlmJob>>(`/admin/llm${buildQs(q)}`, signal),
110 + reviews: (q: Record<string, string | number | undefined>, signal?: AbortSignal) => g<{ items: AdminReview[] } | Page<AdminReview>>(`/admin/reviews${buildQs(q)}`, signal),
111 + resolveReview: (id: string, resolution: 'accepted' | 'rejected', note?: string) => post<unknown>(`/admin/reviews/${enc(id)}`, { resolution, note }),
112 + retractEvent: (id: string, reason: string) => post<unknown>(`/admin/events/${enc(id)}/retract`, { reason }),
113 + restoreEvent: (id: string) => post<unknown>(`/admin/events/${enc(id)}/restore`),
114 + quality: (signal?: AbortSignal) => g<AdminQuality>('/admin/quality', signal),
115 + costs: (days = 30, signal?: AbortSignal) => g<AdminCosts>(`/admin/costs?days=${days}`, signal),
116 + clearCache: () => post<unknown>('/admin/cache/clear'),
117 + };
118 +}
added apps/web/src/lib/cn.ts +4 −0
@@ -0,0 +1,4 @@
1 +/** Tiny class joiner (no dependency). */
2 +export function cn(...parts: Array<string | false | null | undefined>): string {
3 + return parts.filter(Boolean).join(' ');
4 +}
added apps/web/src/lib/countries.ts +54 −0
@@ -0,0 +1,54 @@
1 +/**
2 + * ISO 3166-1 lookup used by the map (world-atlas ids are ISO numeric) and by country chips when the API row is not at
3 + * hand. Names are short English forms. The API's `/countries` remains the source of truth for coverage numbers.
4 + */
5 +export const COUNTRIES: Record<string, { name: string; num: string }> = {
6 + AD: { name: 'Andorra', num: '020' }, AE: { name: 'United Arab Emirates', num: '784' }, AF: { name: 'Afghanistan', num: '004' }, AG: { name: 'Antigua and Barbuda', num: '028' },
7 + AL: { name: 'Albania', num: '008' }, AM: { name: 'Armenia', num: '051' }, AO: { name: 'Angola', num: '024' }, AR: { name: 'Argentina', num: '032' }, AT: { name: 'Austria', num: '040' },
8 + AU: { name: 'Australia', num: '036' }, AZ: { name: 'Azerbaijan', num: '031' }, BA: { name: 'Bosnia and Herzegovina', num: '070' }, BB: { name: 'Barbados', num: '052' }, BD: { name: 'Bangladesh', num: '050' },
9 + BE: { name: 'Belgium', num: '056' }, BF: { name: 'Burkina Faso', num: '854' }, BG: { name: 'Bulgaria', num: '100' }, BH: { name: 'Bahrain', num: '048' }, BI: { name: 'Burundi', num: '108' },
10 + BJ: { name: 'Benin', num: '204' }, BN: { name: 'Brunei', num: '096' }, BO: { name: 'Bolivia', num: '068' }, BR: { name: 'Brazil', num: '076' }, BS: { name: 'Bahamas', num: '044' },
11 + BT: { name: 'Bhutan', num: '064' }, BW: { name: 'Botswana', num: '072' }, BY: { name: 'Belarus', num: '112' }, BZ: { name: 'Belize', num: '084' }, CA: { name: 'Canada', num: '124' },
12 + CD: { name: 'DR Congo', num: '180' }, CF: { name: 'Central African Republic', num: '140' }, CG: { name: 'Congo', num: '178' }, CH: { name: 'Switzerland', num: '756' }, CI: { name: "Côte d'Ivoire", num: '384' },
13 + CL: { name: 'Chile', num: '152' }, CM: { name: 'Cameroon', num: '120' }, CN: { name: 'China', num: '156' }, CO: { name: 'Colombia', num: '170' }, CR: { name: 'Costa Rica', num: '188' },
14 + CU: { name: 'Cuba', num: '192' }, CY: { name: 'Cyprus', num: '196' }, CZ: { name: 'Czechia', num: '203' }, DE: { name: 'Germany', num: '276' }, DJ: { name: 'Djibouti', num: '262' },
15 + DK: { name: 'Denmark', num: '208' }, DO: { name: 'Dominican Republic', num: '214' }, DZ: { name: 'Algeria', num: '012' }, EC: { name: 'Ecuador', num: '218' }, EE: { name: 'Estonia', num: '233' },
16 + EG: { name: 'Egypt', num: '818' }, ER: { name: 'Eritrea', num: '232' }, ES: { name: 'Spain', num: '724' }, ET: { name: 'Ethiopia', num: '231' }, FI: { name: 'Finland', num: '246' },
17 + FJ: { name: 'Fiji', num: '242' }, FR: { name: 'France', num: '250' }, GA: { name: 'Gabon', num: '266' }, GB: { name: 'United Kingdom', num: '826' }, GE: { name: 'Georgia', num: '268' },
18 + GH: { name: 'Ghana', num: '288' }, GL: { name: 'Greenland', num: '304' }, GM: { name: 'Gambia', num: '270' }, GN: { name: 'Guinea', num: '324' }, GQ: { name: 'Equatorial Guinea', num: '226' },
19 + GR: { name: 'Greece', num: '300' }, GT: { name: 'Guatemala', num: '320' }, GW: { name: 'Guinea-Bissau', num: '624' }, GY: { name: 'Guyana', num: '328' }, HK: { name: 'Hong Kong', num: '344' },
20 + HN: { name: 'Honduras', num: '340' }, HR: { name: 'Croatia', num: '191' }, HT: { name: 'Haiti', num: '332' }, HU: { name: 'Hungary', num: '348' }, ID: { name: 'Indonesia', num: '360' },
21 + IE: { name: 'Ireland', num: '372' }, IL: { name: 'Israel', num: '376' }, IN: { name: 'India', num: '356' }, IQ: { name: 'Iraq', num: '368' }, IR: { name: 'Iran', num: '364' },
22 + IS: { name: 'Iceland', num: '352' }, IT: { name: 'Italy', num: '380' }, JM: { name: 'Jamaica', num: '388' }, JO: { name: 'Jordan', num: '400' }, JP: { name: 'Japan', num: '392' },
23 + KE: { name: 'Kenya', num: '404' }, KG: { name: 'Kyrgyzstan', num: '417' }, KH: { name: 'Cambodia', num: '116' }, KP: { name: 'North Korea', num: '408' }, KR: { name: 'South Korea', num: '410' },
24 + KW: { name: 'Kuwait', num: '414' }, KZ: { name: 'Kazakhstan', num: '398' }, LA: { name: 'Laos', num: '418' }, LB: { name: 'Lebanon', num: '422' }, LK: { name: 'Sri Lanka', num: '144' },
25 + LR: { name: 'Liberia', num: '430' }, LS: { name: 'Lesotho', num: '426' }, LT: { name: 'Lithuania', num: '440' }, LU: { name: 'Luxembourg', num: '442' }, LV: { name: 'Latvia', num: '428' },
26 + LY: { name: 'Libya', num: '434' }, MA: { name: 'Morocco', num: '504' }, MD: { name: 'Moldova', num: '498' }, ME: { name: 'Montenegro', num: '499' }, MG: { name: 'Madagascar', num: '450' },
27 + MK: { name: 'North Macedonia', num: '807' }, ML: { name: 'Mali', num: '466' }, MM: { name: 'Myanmar', num: '104' }, MN: { name: 'Mongolia', num: '496' }, MR: { name: 'Mauritania', num: '478' },
28 + MT: { name: 'Malta', num: '470' }, MU: { name: 'Mauritius', num: '480' }, MW: { name: 'Malawi', num: '454' }, MX: { name: 'Mexico', num: '484' }, MY: { name: 'Malaysia', num: '458' },
29 + MZ: { name: 'Mozambique', num: '508' }, NA: { name: 'Namibia', num: '516' }, NE: { name: 'Niger', num: '562' }, NG: { name: 'Nigeria', num: '566' }, NI: { name: 'Nicaragua', num: '558' },
30 + NL: { name: 'Netherlands', num: '528' }, NO: { name: 'Norway', num: '578' }, NP: { name: 'Nepal', num: '524' }, NZ: { name: 'New Zealand', num: '554' }, OM: { name: 'Oman', num: '512' },
31 + PA: { name: 'Panama', num: '591' }, PE: { name: 'Peru', num: '604' }, PG: { name: 'Papua New Guinea', num: '598' }, PH: { name: 'Philippines', num: '608' }, PK: { name: 'Pakistan', num: '586' },
32 + PL: { name: 'Poland', num: '616' }, PR: { name: 'Puerto Rico', num: '630' }, PS: { name: 'Palestine', num: '275' }, PT: { name: 'Portugal', num: '620' }, PY: { name: 'Paraguay', num: '600' },
33 + QA: { name: 'Qatar', num: '634' }, RO: { name: 'Romania', num: '642' }, RS: { name: 'Serbia', num: '688' }, RU: { name: 'Russia', num: '643' }, RW: { name: 'Rwanda', num: '646' },
34 + SA: { name: 'Saudi Arabia', num: '682' }, SB: { name: 'Solomon Islands', num: '090' }, SD: { name: 'Sudan', num: '729' }, SE: { name: 'Sweden', num: '752' }, SG: { name: 'Singapore', num: '702' },
35 + SI: { name: 'Slovenia', num: '705' }, SK: { name: 'Slovakia', num: '703' }, SL: { name: 'Sierra Leone', num: '694' }, SN: { name: 'Senegal', num: '686' }, SO: { name: 'Somalia', num: '706' },
36 + SR: { name: 'Suriname', num: '740' }, SS: { name: 'South Sudan', num: '728' }, SV: { name: 'El Salvador', num: '222' }, SY: { name: 'Syria', num: '760' }, SZ: { name: 'Eswatini', num: '748' },
37 + TD: { name: 'Chad', num: '148' }, TG: { name: 'Togo', num: '768' }, TH: { name: 'Thailand', num: '764' }, TJ: { name: 'Tajikistan', num: '762' }, TL: { name: 'Timor-Leste', num: '626' },
38 + TM: { name: 'Turkmenistan', num: '795' }, TN: { name: 'Tunisia', num: '788' }, TR: { name: 'Türkiye', num: '792' }, TT: { name: 'Trinidad and Tobago', num: '780' }, TW: { name: 'Taiwan', num: '158' },
39 + TZ: { name: 'Tanzania', num: '834' }, UA: { name: 'Ukraine', num: '804' }, UG: { name: 'Uganda', num: '800' }, US: { name: 'United States', num: '840' }, UY: { name: 'Uruguay', num: '858' },
40 + UZ: { name: 'Uzbekistan', num: '860' }, VE: { name: 'Venezuela', num: '862' }, VN: { name: 'Vietnam', num: '704' }, VU: { name: 'Vanuatu', num: '548' }, YE: { name: 'Yemen', num: '887' },
41 + ZA: { name: 'South Africa', num: '710' }, ZM: { name: 'Zambia', num: '894' }, ZW: { name: 'Zimbabwe', num: '716' },
42 +};
43 +
44 +const BY_NUM: Record<string, string> = Object.fromEntries(Object.entries(COUNTRIES).map(([a2, v]) => [v.num, a2]));
45 +
46 +export function countryName(code: string | null | undefined): string {
47 + if (!code) return '—';
48 + return COUNTRIES[code.toUpperCase()]?.name ?? code.toUpperCase();
49 +}
50 +export function alpha2FromNumeric(num: string | number | null | undefined): string | null {
51 + if (num === null || num === undefined) return null;
52 + const k = String(num).padStart(3, '0');
53 + return BY_NUM[k] ?? null;
54 +}
added apps/web/src/lib/event-styles.ts +154 −0
@@ -0,0 +1,154 @@
1 +/**
2 + * Event-type visual language (spec §21 taxonomy). Each type maps to a CSS token `--ev-<key>` declared in globals.css
3 + * (both themes), a short label and a lucide icon name used by `EventTypeBadge`. Unknown types fall back to `other`.
4 + */
5 +export type EventTypeKey =
6 + | 'product'
7 + | 'pricing'
8 + | 'hiring'
9 + | 'leadership'
10 + | 'location'
11 + | 'financing'
12 + | 'ma'
13 + | 'partnership'
14 + | 'strategy'
15 + | 'technology'
16 + | 'legal'
17 + | 'marketing'
18 + | 'developer'
19 + | 'security'
20 + | 'operations'
21 + | 'sustainability'
22 + | 'investor_relations'
23 + | 'communication'
24 + | 'other';
25 +
26 +export interface EventStyle {
27 + key: EventTypeKey;
28 + label: string;
29 + /** CSS color expression, usable in `style={{ color }}`. */
30 + color: string;
31 + soft: string;
32 +}
33 +
34 +const TYPE_TO_KEY: Record<string, EventTypeKey> = {
35 + PRODUCT: 'product',
36 + PRICING: 'pricing',
37 + HIRING: 'hiring',
38 + LEADERSHIP: 'leadership',
39 + LOCATION: 'location',
40 + FINANCING: 'financing',
41 + 'M&A': 'ma',
42 + MA: 'ma',
43 + M_A: 'ma',
44 + MERGER: 'ma',
45 + ACQUISITION: 'ma',
46 + PARTNERSHIP: 'partnership',
47 + STRATEGY: 'strategy',
48 + TECHNOLOGY: 'technology',
49 + LEGAL: 'legal',
50 + MARKETING: 'marketing',
51 + DEVELOPER: 'developer',
52 + SECURITY: 'security',
53 + OPERATIONS: 'operations',
54 + SUSTAINABILITY: 'sustainability',
55 + INVESTOR_RELATIONS: 'investor_relations',
56 + COMMUNICATION: 'communication',
57 + NEWS: 'communication',
58 + OTHER: 'other',
59 +};
60 +
61 +const LABELS: Record<EventTypeKey, string> = {
62 + product: 'Product',
63 + pricing: 'Pricing',
64 + hiring: 'Hiring',
65 + leadership: 'Leadership',
66 + location: 'Location',
67 + financing: 'Financing',
68 + ma: 'M&A',
69 + partnership: 'Partnership',
70 + strategy: 'Strategy',
71 + technology: 'Technology',
72 + legal: 'Legal',
73 + marketing: 'Marketing',
74 + developer: 'Developer',
75 + security: 'Security',
76 + operations: 'Operations',
77 + sustainability: 'Sustainability',
78 + investor_relations: 'Investor relations',
79 + communication: 'Communication',
80 + other: 'Other',
81 +};
82 +
83 +export function eventKey(type: string | null | undefined): EventTypeKey {
84 + if (!type) return 'other';
85 + return TYPE_TO_KEY[type.toUpperCase()] ?? 'other';
86 +}
87 +
88 +export function eventStyle(type: string | null | undefined): EventStyle {
89 + const key = eventKey(type);
90 + return { key, label: LABELS[key], color: `var(--ev-${key})`, soft: `color-mix(in srgb, var(--ev-${key}) 12%, transparent)` };
91 +}
92 +
93 +/** Ordered list for filters and legends (the spec taxonomy order). */
94 +export const EVENT_TYPES: string[] = [
95 + 'PRODUCT',
96 + 'PRICING',
97 + 'HIRING',
98 + 'LEADERSHIP',
99 + 'LOCATION',
100 + 'FINANCING',
101 + 'M&A',
102 + 'PARTNERSHIP',
103 + 'STRATEGY',
104 + 'TECHNOLOGY',
105 + 'LEGAL',
106 + 'MARKETING',
107 + 'DEVELOPER',
108 + 'SECURITY',
109 + 'OPERATIONS',
110 + 'SUSTAINABILITY',
111 + 'INVESTOR_RELATIONS',
112 + 'COMMUNICATION',
113 + 'OTHER',
114 +];
115 +
116 +export function eventTypeLabel(type: string | null | undefined): string {
117 + return LABELS[eventKey(type)];
118 +}
119 +
120 +/** Subtype → careful human label. Anything unknown is humanised, never invented. */
121 +const SUBTYPE_LABELS: Record<string, string> = {
122 + PRODUCT_LAUNCH: 'Product launch',
123 + PRODUCT_REMOVAL: 'Product no longer listed',
124 + PRODUCT_RENAME: 'Product renamed',
125 + PRICE_INCREASE: 'Price increase',
126 + PRICE_DECREASE: 'Price decrease',
127 + NEW_PRICING_TIER: 'New pricing tier',
128 + PRICING_CHANGE: 'Pricing change',
129 + JOB_COUNT_INCREASE: 'Job count increase',
130 + JOB_COUNT_DECREASE: 'Job count decrease',
131 + NEW_JOB: 'New listing',
132 + JOB_REMOVED: 'Listing no longer visible',
133 + NEW_EXECUTIVE: 'New executive listed',
134 + EXECUTIVE_REMOVED: 'Executive no longer listed',
135 + LEADERSHIP_CHANGE: 'Leadership page changed',
136 + NEW_OFFICE: 'New office listed',
137 + OFFICE_REMOVED: 'Office no longer listed',
138 + NEW_LOCATION: 'New location listed',
139 + COUNTRY_EXPANSION: 'Country expansion',
140 + NEW_PARTNERSHIP: 'New partnership',
141 + ACQUISITION: 'Acquisition',
142 + DIVESTITURE: 'Divestiture',
143 + API_LAUNCH: 'API launch',
144 + DOCUMENTATION_CHANGE: 'Documentation change',
145 + DOC_CHANGE: 'Documentation change',
146 + TERMS_CHANGE: 'Terms change',
147 + BRAND_REPOSITIONING: 'Brand repositioning',
148 + NEWS_RELEASE: 'News release',
149 + SECURITY_NOTICE: 'Security notice',
150 +};
151 +export function subtypeLabel(sub: string | null | undefined): string {
152 + if (!sub) return '';
153 + return SUBTYPE_LABELS[sub.toUpperCase()] ?? sub.replace(/[_-]+/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase());
154 +}
added apps/web/src/lib/fonts.ts +9 −0
@@ -0,0 +1,9 @@
1 +/**
2 + * Geist Sans (UI) + Geist Mono (ids, numbers, telemetry), self-hosted through the `geist` package — no network at build time.
3 + * Exposed as CSS variables consumed by globals.css (`--font-geist-sans`, `--font-geist-mono`).
4 + */
5 +import { GeistMono } from 'geist/font/mono';
6 +import { GeistSans } from 'geist/font/sans';
7 +
8 +export const fontUi = GeistSans;
9 +export const fontMono = GeistMono;
added apps/web/src/lib/format.ts +203 −0
@@ -0,0 +1,203 @@
1 +/**
2 + * Formatting helpers. API timestamps are UTC ISO strings; absolute dates render in UTC (identical on server and client),
3 + * relative times are rendered client-side after mount (`components/ui/live.tsx`). Missing values → em dash, never a fake number.
4 + */
5 +import type { Num } from './types';
6 +
7 +const nf0 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
8 +const nf1 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
9 +const nf2 = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
10 +const nfCompact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
11 +
12 +export const DASH = '—';
13 +
14 +export function num(v: unknown): number | null {
15 + if (v === null || v === undefined || v === '') return null;
16 + const n = typeof v === 'number' ? v : Number(v);
17 + return Number.isFinite(n) ? n : null;
18 +}
19 +export function fmtInt(v: Num | undefined | unknown): string {
20 + const n = num(v);
21 + return n === null ? DASH : nf0.format(n);
22 +}
23 +export function fmt1(v: Num | undefined | unknown): string {
24 + const n = num(v);
25 + return n === null ? DASH : nf1.format(n);
26 +}
27 +export function fmt2(v: Num | undefined | unknown): string {
28 + const n = num(v);
29 + return n === null ? DASH : nf2.format(n);
30 +}
31 +export function fmtCompact(v: Num | undefined | unknown): string {
32 + const n = num(v);
33 + return n === null ? DASH : nfCompact.format(n);
34 +}
35 +/** 0–100 score with one decimal (API rounds to 1 decimal). */
36 +export function fmtScore(v: Num | undefined | unknown): string {
37 + const n = num(v);
38 + return n === null ? DASH : nf1.format(n);
39 +}
40 +/** Percentage from a 0–1 ratio or a 0–100 value (`ratio` flag). */
41 +export function fmtPct(v: Num | undefined | unknown, digits = 1, ratio = false): string {
42 + const n = num(v);
43 + if (n === null) return DASH;
44 + return `${(ratio ? n * 100 : n).toFixed(digits)} %`;
45 +}
46 +/** Signed percentage: +12.3 % / −4.0 % / 0.0 %. */
47 +export function fmtPctSigned(v: Num | undefined | unknown, digits = 1): string {
48 + const n = num(v);
49 + if (n === null) return DASH;
50 + const sign = n > 0 ? '+' : n < 0 ? '−' : '';
51 + return `${sign}${Math.abs(n).toFixed(digits)} %`;
52 +}
53 +export function fmtSigned(v: Num | undefined | unknown, digits = 0): string {
54 + const n = num(v);
55 + if (n === null) return DASH;
56 + const f = digits ? Math.abs(n).toFixed(digits) : nf0.format(Math.abs(n));
57 + return n > 0 ? `+${f}` : n < 0 ? `−${f}` : digits ? (0).toFixed(digits) : '0';
58 +}
59 +export function fmtBytes(v: Num | undefined | unknown): string {
60 + const n = num(v);
61 + if (n === null) return DASH;
62 + if (n >= 1e12) return `${(n / 1e12).toFixed(2)} TB`;
63 + if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`;
64 + if (n >= 1e6) return `${(n / 1e6).toFixed(0)} MB`;
65 + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kB`;
66 + return `${n} B`;
67 +}
68 +export function fmtUsd(v: Num | undefined | unknown, digits = 2): string {
69 + const n = num(v);
70 + return n === null ? DASH : `$${n.toFixed(digits)}`;
71 +}
72 +export function fmtPrice(price: Num | undefined, currency: string | null | undefined, text?: string | null): string {
73 + const n = num(price);
74 + if (n === null) return text ?? DASH;
75 + const cur = (currency ?? 'USD').toUpperCase();
76 + const sym = cur === 'USD' ? '$' : cur === 'EUR' ? '€' : cur === 'GBP' ? '£' : `${cur} `;
77 + return `${sym}${Number.isInteger(n) ? nf0.format(n) : nf2.format(n)}`;
78 +}
79 +
80 +/** ISO date/timestamp → "11 Sept 2026" (UTC). */
81 +export function fmtDate(v: string | null | undefined): string {
82 + if (!v) return DASH;
83 + const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v);
84 + if (Number.isNaN(d.getTime())) return v;
85 + return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
86 +}
87 +export function fmtDateShort(v: string | null | undefined): string {
88 + if (!v) return DASH;
89 + const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v);
90 + if (Number.isNaN(d.getTime())) return v;
91 + return d.toLocaleDateString('en-GB', { month: 'short', day: 'numeric', timeZone: 'UTC' });
92 +}
93 +export function fmtDateTime(v: string | null | undefined): string {
94 + if (!v) return DASH;
95 + const d = new Date(v);
96 + if (Number.isNaN(d.getTime())) return DASH;
97 + return `${d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })} ${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC`;
98 +}
99 +export function fmtTime(v: string | null | undefined): string {
100 + if (!v) return DASH;
101 + const d = new Date(v);
102 + if (Number.isNaN(d.getTime())) return DASH;
103 + return `${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC' })}`;
104 +}
105 +/** Day label for timeline groups: "Today", "Yesterday", else the date (UTC). */
106 +export function fmtDayLabel(day: string, now: number = Date.now()): string {
107 + const today = new Date(now).toISOString().slice(0, 10);
108 + const yesterday = new Date(now - 86_400_000).toISOString().slice(0, 10);
109 + if (day === today) return 'Today';
110 + if (day === yesterday) return 'Yesterday';
111 + return fmtDate(day);
112 +}
113 +/** "17 sec ago" · "3 min ago" · "2 h ago" · "5 d ago" — precise at the second for the live feed. */
114 +export function fmtAgo(v: string | null | undefined, now: number = Date.now()): string {
115 + if (!v) return DASH;
116 + const t = new Date(v).getTime();
117 + if (Number.isNaN(t)) return DASH;
118 + const s = Math.max(0, Math.round((now - t) / 1000));
119 + if (s < 5) return 'just now';
120 + if (s < 60) return `${s} sec ago`;
121 + const m = Math.floor(s / 60);
122 + if (m < 60) return `${m} min ago`;
123 + const h = Math.floor(m / 60);
124 + if (h < 48) return `${h} h ago`;
125 + const d = Math.floor(h / 24);
126 + if (d < 60) return `${d} d ago`;
127 + return fmtDate(v);
128 +}
129 +export function fmtDuration(seconds: Num | undefined | unknown): string {
130 + const n = num(seconds);
131 + if (n === null) return DASH;
132 + if (n < 60) return `${Math.round(n)} s`;
133 + if (n < 3600) return `${Math.round(n / 60)} min`;
134 + if (n < 86400) return `${(n / 3600).toFixed(n < 7200 ? 1 : 0)} h`;
135 + return `${Math.round(n / 86400)} d`;
136 +}
137 +export function fmtDays(days: Num | undefined | unknown): string {
138 + const n = num(days);
139 + if (n === null) return DASH;
140 + if (n < 1) return '< 1 day';
141 + if (n < 60) return `${Math.round(n)} ${plural(Math.round(n), 'day')}`;
142 + if (n < 730) return `${(n / 30.44).toFixed(0)} months`;
143 + return `${(n / 365.25).toFixed(1)} years`;
144 +}
145 +export function plural(n: number, one: string, many = `${one}s`): string {
146 + return n === 1 ? one : many;
147 +}
148 +export function hostOf(url: string | null | undefined): string | null {
149 + if (!url) return null;
150 + try {
151 + return new URL(url).hostname.replace(/^www\./, '');
152 + } catch {
153 + return null;
154 + }
155 +}
156 +export function pathOf(url: string | null | undefined): string {
157 + if (!url) return DASH;
158 + try {
159 + const u = new URL(url);
160 + return `${u.hostname.replace(/^www\./, '')}${u.pathname === '/' ? '' : u.pathname}`;
161 + } catch {
162 + return url;
163 + }
164 +}
165 +/** Constant → words: PRODUCT_LAUNCH → "Product launch"; job_count_increase → "Job count increase". */
166 +export function humanize(s: string | null | undefined): string {
167 + if (!s) return DASH;
168 + const w = s.replace(/[_-]+/g, ' ').trim().toLowerCase();
169 + return w.charAt(0).toUpperCase() + w.slice(1);
170 +}
171 +export function titleCase(s: string | null | undefined): string {
172 + if (!s) return DASH;
173 + return s.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
174 +}
175 +export function truncate(s: string | null | undefined, n = 140): string {
176 + if (!s) return '';
177 + return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
178 +}
179 +/** Significance 0–1 → band per spec §20. */
180 +export function significanceBand(v: Num | undefined | unknown): 'noise' | 'minor' | 'meaningful' | 'major' | 'critical' | null {
181 + const n = num(v);
182 + if (n === null) return null;
183 + if (n < 0.2) return 'noise';
184 + if (n < 0.4) return 'minor';
185 + if (n < 0.65) return 'meaningful';
186 + if (n < 0.85) return 'major';
187 + return 'critical';
188 +}
189 +/** Importance 0–1 (or 0–100) → 0..3 steps for the meter. */
190 +export function importanceSteps(v: Num | undefined | unknown): 0 | 1 | 2 | 3 {
191 + const n = num(v);
192 + if (n === null) return 0;
193 + const x = n > 1 ? n / 100 : n;
194 + if (x >= 0.8) return 3;
195 + if (x >= 0.55) return 2;
196 + if (x >= 0.3) return 1;
197 + return 0;
198 +}
199 +export function toneOf(v: Num | undefined | unknown): 'positive' | 'negative' | 'neutral' {
200 + const n = num(v);
201 + if (n === null || n === 0) return 'neutral';
202 + return n > 0 ? 'positive' : 'negative';
203 +}
added apps/web/src/lib/owner.ts +95 −0
@@ -0,0 +1,95 @@
1 +'use client';
2 +/**
3 + * Owner token for watchlists and alerts (no account, spec §55): a random string generated once in the browser
4 + * (`crypto.randomUUID()` twice → 72 chars) and kept in localStorage. Sent as `X-CA-Owner-Token`; the API stores a hash.
5 + * Losing the browser storage loses the watchlist — the /watchlist page says so and offers export/import of the token.
6 + */
7 +import { useEffect, useState } from 'react';
8 +
9 +export const OWNER_KEY = 'ca-owner-token';
10 +const EVENT = 'ca-owner-change';
11 +
12 +export function readOwnerToken(): string | null {
13 + if (typeof window === 'undefined') return null;
14 + try {
15 + const v = window.localStorage.getItem(OWNER_KEY);
16 + return v && v.length >= 24 ? v : null;
17 + } catch {
18 + return null;
19 + }
20 +}
21 +
22 +export function ensureOwnerToken(): string {
23 + const cur = readOwnerToken();
24 + if (cur) return cur;
25 + const t = `${crypto.randomUUID()}${crypto.randomUUID()}`.replace(/-/g, '');
26 + try {
27 + window.localStorage.setItem(OWNER_KEY, t);
28 + window.dispatchEvent(new CustomEvent(EVENT));
29 + } catch {
30 + /* storage disabled: token lives for this page only */
31 + }
32 + return t;
33 +}
34 +
35 +export function setOwnerToken(t: string): boolean {
36 + if (t.trim().length < 24) return false;
37 + try {
38 + window.localStorage.setItem(OWNER_KEY, t.trim());
39 + window.dispatchEvent(new CustomEvent(EVENT));
40 + return true;
41 + } catch {
42 + return false;
43 + }
44 +}
45 +
46 +/** Token after mount (null during SSR / first paint). `create` generates one when absent. */
47 +export function useOwnerToken(create = false): string | null {
48 + const [token, setToken] = useState<string | null>(null);
49 + useEffect(() => {
50 + const read = () => setToken(create ? ensureOwnerToken() : readOwnerToken());
51 + read();
52 + window.addEventListener(EVENT, read);
53 + window.addEventListener('storage', read);
54 + return () => {
55 + window.removeEventListener(EVENT, read);
56 + window.removeEventListener('storage', read);
57 + };
58 + }, [create]);
59 + return token;
60 +}
61 +
62 +/** Local mirror of watched slugs so watch buttons render instantly and work when the API is briefly unavailable. */
63 +export const WATCHED_KEY = 'ca-watched';
64 +const WATCHED_EVENT = 'ca-watched-change';
65 +export function readWatched(): string[] {
66 + if (typeof window === 'undefined') return [];
67 + try {
68 + const arr = JSON.parse(window.localStorage.getItem(WATCHED_KEY) ?? '[]') as unknown;
69 + return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : [];
70 + } catch {
71 + return [];
72 + }
73 +}
74 +export function writeWatched(slugs: string[]) {
75 + try {
76 + window.localStorage.setItem(WATCHED_KEY, JSON.stringify([...new Set(slugs)]));
77 + } catch {
78 + /* ignore */
79 + }
80 + window.dispatchEvent(new CustomEvent(WATCHED_EVENT));
81 +}
82 +export function useWatched(): string[] {
83 + const [w, setW] = useState<string[]>([]);
84 + useEffect(() => {
85 + const read = () => setW(readWatched());
86 + read();
87 + window.addEventListener(WATCHED_EVENT, read);
88 + window.addEventListener('storage', read);
89 + return () => {
90 + window.removeEventListener(WATCHED_EVENT, read);
91 + window.removeEventListener('storage', read);
92 + };
93 + }, []);
94 + return w;
95 +}
added apps/web/src/lib/params.ts +22 −0
@@ -0,0 +1,22 @@
1 +/** Next 16 search params arrive as `Promise<Record<string, string | string[] | undefined>>`; these helpers read them safely. */
2 +export type SP = Record<string, string | string[] | undefined>;
3 +
4 +export function str(v: string | string[] | undefined): string | undefined {
5 + if (Array.isArray(v)) return v[0];
6 + return v === '' ? undefined : v;
7 +}
8 +export function int(v: string | string[] | undefined, fallback: number, min = 1, max = 100000): number {
9 + const n = Number(str(v));
10 + if (!Number.isFinite(n)) return fallback;
11 + return Math.min(max, Math.max(min, Math.floor(n)));
12 +}
13 +export function bool(v: string | string[] | undefined): boolean {
14 + const s = str(v);
15 + return s === '1' || s === 'true';
16 +}
17 +/** Current params as a flat string record (for `withParams`). */
18 +export function flat(sp: SP): Record<string, string | undefined> {
19 + const out: Record<string, string | undefined> = {};
20 + for (const [k, v] of Object.entries(sp)) out[k] = str(v);
21 + return out;
22 +}
added apps/web/src/lib/prepaint.ts +13 −0
@@ -0,0 +1,13 @@
1 +/**
2 + * Inline scripts run in <head> before paint (server-safe module: no 'use client', so the root layout can embed the strings).
3 + * They apply the persisted theme and density to <html> so the first frame is right. Keys are shared with
4 + * `components/layout/theme.tsx` and `components/layout/density.tsx`.
5 + */
6 +export const THEME_KEY = 'ca-theme';
7 +export const DENSITY_KEY = 'ca-density';
8 +
9 +export const THEME_SCRIPT = `(function(){try{var k='${THEME_KEY}',p=localStorage.getItem(k),m=window.matchMedia('(prefers-color-scheme: dark)'),t=(p==='light'||p==='dark')?p:(m.matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);document.documentElement.style.colorScheme=t;}catch(e){}})();`;
10 +
11 +export const DENSITY_SCRIPT = `(function(){try{var d=localStorage.getItem('${DENSITY_KEY}');if(d==='compact'){document.documentElement.setAttribute('data-density',d);}}catch(e){}})();`;
12 +
13 +export const PREPAINT_SCRIPT = THEME_SCRIPT + DENSITY_SCRIPT;
added apps/web/src/lib/site.ts +185 −0
@@ -0,0 +1,185 @@
1 +/** Site-wide constants: names, URLs, navigation, route builders. Server- and client-safe (no 'use client'). */
2 +
3 +export const SITE_NAME = 'Company Atlas';
4 +export const TAGLINE = 'The Live Atlas of Global Companies';
5 +export const DESCRIPTION =
6 + 'Company Atlas continuously observes the public web to track how companies evolve — products, hiring, pricing, leadership, locations, technology, strategy and more.';
7 +export const ALT_TAGLINE = 'Thousands of companies. Millions of observations. One continuously growing historical record.';
8 +
9 +export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.company-atlas.co').replace(/\/$/, '');
10 +export const PUBLIC_API_BASE = `${SITE_URL}/api/v1`;
11 +
12 +export const AUTHOR_NAME = 'Simon-Pierre Boucher';
13 +export const CONTACT_EMAIL = 'contact@spboucher.ai';
14 +export const HOST_NAME = 'MacLustr';
15 +export const HOST_URL = 'https://www.maclustr.io';
16 +export const BOT_UA = 'CompanyAtlasBot';
17 +
18 +export const THEME_LIGHT = '#f7f7f4';
19 +export const THEME_DARK = '#0a0d12';
20 +
21 +export type NavItem = { href: string; label: string; hint?: string };
22 +
23 +export const primaryNav: NavItem[] = [
24 + { href: '/', label: 'Home' },
25 + { href: '/live', label: 'Live' },
26 + { href: '/companies', label: 'Companies' },
27 + { href: '/events', label: 'Events' },
28 + { href: '/rankings', label: 'Rankings' },
29 + { href: '/industry', label: 'Industries' },
30 + { href: '/country', label: 'Countries' },
31 +];
32 +
33 +export const footerGroups: { label: string; items: NavItem[] }[] = [
34 + {
35 + label: 'Atlas',
36 + items: [
37 + { href: '/companies', label: 'Companies' },
38 + { href: '/events', label: 'Events' },
39 + { href: '/live', label: 'Live feed' },
40 + { href: '/rankings', label: 'Rankings' },
41 + { href: '/industry', label: 'Industries' },
42 + { href: '/country', label: 'Countries' },
43 + { href: '/company/compare', label: 'Compare' },
44 + ],
45 + },
46 + {
47 + label: 'Data',
48 + items: [
49 + { href: '/api', label: 'API & exports' },
50 + { href: '/methodology', label: 'Methodology' },
51 + { href: '/system', label: 'System status' },
52 + { href: '/watchlist', label: 'Watchlist' },
53 + ],
54 + },
55 + {
56 + label: 'About',
57 + items: [
58 + { href: '/about', label: 'About Company Atlas' },
59 + { href: '/bot', label: 'Our crawler' },
60 + { href: `mailto:${CONTACT_EMAIL}`, label: 'Contact' },
61 + ],
62 + },
63 +];
64 +
65 +export const EXAMPLE_QUERIES = ['companies hiring AI engineers in Canada', 'pricing changes in SaaS this month', 'new offices opened in Germany', 'Stripe'];
66 +
67 +const enc = encodeURIComponent;
68 +export const routes = {
69 + home: () => '/',
70 + live: (q?: Record<string, string | undefined>) => withQuery('/live', q),
71 + companies: (q?: Record<string, string | undefined>) => withQuery('/companies', q),
72 + company: (slug: string, tab?: string) => (tab && tab !== 'overview' ? `/company/${enc(slug)}?tab=${tab}` : `/company/${enc(slug)}`),
73 + compare: (slugs: string[] = []) => (slugs.length ? `/company/compare?companies=${slugs.map(enc).join(',')}` : '/company/compare'),
74 + events: (q?: Record<string, string | undefined>) => withQuery('/events', q),
75 + event: (id: string) => `/events/${enc(id)}`,
76 + change: (id: string) => `/change/${enc(id)}`,
77 + sensor: (id: string) => `/sensor/${enc(id)}`,
78 + snapshot: (id: string) => `/snapshot/${enc(id)}`,
79 + snapshotDiff: (id: string, other: string) => `/snapshot/${enc(id)}/diff/${enc(other)}`,
80 + rankings: (kind?: string, window?: string) => withQuery('/rankings', { kind, window }),
81 + industries: () => '/industry',
82 + industry: (slug: string) => `/industry/${enc(slug)}`,
83 + countries: () => '/country',
84 + country: (code: string) => `/country/${enc(code.toLowerCase())}`,
85 + search: (q: string) => `/search?q=${enc(q)}`,
86 + watchlist: () => '/watchlist',
87 + system: () => '/system',
88 + about: () => '/about',
89 + methodology: () => '/methodology',
90 + api: () => '/api',
91 + bot: () => '/bot',
92 + admin: (module?: string) => (module ? `/admin/${module}` : '/admin'),
93 +};
94 +
95 +export function withQuery(base: string, q?: Record<string, string | number | undefined | null>): string {
96 + if (!q) return base;
97 + const p = new URLSearchParams();
98 + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
99 + const s = p.toString();
100 + return s ? `${base}?${s}` : base;
101 +}
102 +
103 +export const CONFIDENCE_LABELS: Record<string, string> = {
104 + VERIFIED: 'Verified',
105 + HIGH_CONFIDENCE: 'High confidence',
106 + LIKELY: 'Likely',
107 + INFERRED: 'Inferred',
108 + LOW_CONFIDENCE: 'Low confidence',
109 +};
110 +
111 +export const METRIC_LABELS: Record<string, string> = {
112 + activity_score: 'Activity Score',
113 + hiring_momentum_7d: 'Hiring Momentum 7d',
114 + hiring_momentum_30d: 'Hiring Momentum 30d',
115 + hiring_momentum_90d: 'Hiring Momentum 90d',
116 + open_jobs: 'Open jobs',
117 + ai_adoption: 'AI Adoption',
118 + product_velocity: 'Product Velocity',
119 + geo_expansion: 'Geographic Expansion',
120 + developer_momentum: 'Developer Momentum',
121 + communication_activity: 'Communication Activity',
122 + pricing_activity: 'Pricing Activity',
123 + leadership_activity: 'Leadership Activity',
124 + corporate_change_index: 'Corporate Change Index',
125 + anomaly_score: 'Anomaly Score',
126 + historical_coverage: 'Historical coverage',
127 +};
128 +export const METRIC_SHORT: Record<string, string> = {
129 + activity_score: 'Activity',
130 + hiring_momentum_30d: 'Hiring 30d',
131 + product_velocity: 'Product velocity',
132 + ai_adoption: 'AI adoption',
133 + corporate_change_index: 'CCI',
134 + geo_expansion: 'Geo expansion',
135 + developer_momentum: 'Developer',
136 + open_jobs: 'Open jobs',
137 +};
138 +
139 +export const RANKING_KINDS: { id: string; label: string; short: string; unit: 'score' | 'pct' | 'count' }[] = [
140 + { id: 'most_active', label: 'Most Active', short: 'Active', unit: 'score' },
141 + { id: 'hiring_growth', label: 'Fastest Hiring Growth', short: 'Hiring ↑', unit: 'pct' },
142 + { id: 'hiring_decline', label: 'Fastest Hiring Decline', short: 'Hiring ↓', unit: 'pct' },
143 + { id: 'product_velocity', label: 'Highest Product Velocity', short: 'Product', unit: 'score' },
144 + { id: 'ai_active', label: 'Most AI-Active', short: 'AI', unit: 'score' },
145 + { id: 'geo_expansion', label: 'Fastest Geographic Expansion', short: 'Geo', unit: 'score' },
146 + { id: 'developer_momentum', label: 'Highest Developer Momentum', short: 'Developer', unit: 'score' },
147 + { id: 'pricing_changes', label: 'Most Pricing Changes', short: 'Pricing', unit: 'count' },
148 + { id: 'unusual_activity', label: 'Most Unusual Activity', short: 'Unusual', unit: 'score' },
149 +];
150 +export const RANKING_WINDOWS = ['24h', '7d', '30d', '90d', '1y'] as const;
151 +
152 +export const TIMELINE_FILTERS = ['all', 'products', 'jobs', 'pricing', 'leadership', 'locations', 'legal', 'news', 'developer'] as const;
153 +
154 +export const SENSOR_TIER_LABELS: Record<string, string> = { A: '5–15 min', B: '30–60 min', C: '6 h', D: '24 h', E: '3–7 days' };
155 +
156 +export const SURFACE_LABELS: Record<string, string> = {
157 + homepage: 'Homepage',
158 + about: 'About',
159 + careers: 'Careers',
160 + jobs: 'Jobs',
161 + newsroom: 'Newsroom',
162 + press: 'Press',
163 + blog: 'Blog',
164 + products: 'Products',
165 + pricing: 'Pricing',
166 + leadership: 'Leadership',
167 + team: 'Team',
168 + locations: 'Locations',
169 + investor_relations: 'Investor relations',
170 + documentation: 'Documentation',
171 + developer: 'Developer',
172 + api: 'API',
173 + changelog: 'Changelog',
174 + legal: 'Legal',
175 + terms: 'Terms',
176 + privacy: 'Privacy',
177 + security: 'Security',
178 + status: 'Status page',
179 + sustainability: 'Sustainability',
180 + partners: 'Partners',
181 + customers: 'Customers',
182 + sitemap: 'Sitemap',
183 + feed: 'Feed',
184 + other: 'Other',
185 +};
added apps/web/src/lib/types.ts +751 −0
@@ -0,0 +1,751 @@
1 +/**
2 + * TypeScript mirror of `docs/API.md` (contract v1). Keep in sync with the FastAPI routers; never widen a shape here to
3 + * paper over an API gap — document it in `docs/FRONTEND.md` instead.
4 + */
5 +
6 +export type Num = number | string | null;
7 +
8 +export type Metric =
9 + | 'activity_score'
10 + | 'hiring_momentum_7d'
11 + | 'hiring_momentum_30d'
12 + | 'hiring_momentum_90d'
13 + | 'open_jobs'
14 + | 'ai_adoption'
15 + | 'product_velocity'
16 + | 'geo_expansion'
17 + | 'developer_momentum'
18 + | 'communication_activity'
19 + | 'pricing_activity'
20 + | 'leadership_activity'
21 + | 'corporate_change_index'
22 + | 'anomaly_score'
23 + | 'historical_coverage';
24 +
25 +export type ConfidenceLabel = 'VERIFIED' | 'HIGH_CONFIDENCE' | 'LIKELY' | 'INFERRED' | 'LOW_CONFIDENCE';
26 +
27 +export interface Page<T> {
28 + items: T[];
29 + page: number;
30 + per_page: number;
31 + total: number;
32 + pages: number;
33 + meta?: Record<string, unknown>;
34 +}
35 +
36 +export interface CompanyRef {
37 + id: string;
38 + slug: string;
39 + display_name: string;
40 + canonical_domain: string;
41 + country: string | null;
42 + logo_url: string | null;
43 +}
44 +
45 +export interface CompanyCounts {
46 + sensors: number;
47 + observations: number;
48 + changes: number;
49 + events: number;
50 + jobs_open: number;
51 +}
52 +
53 +export interface CompanyCard {
54 + id: string;
55 + slug: string;
56 + display_name: string;
57 + legal_name: string | null;
58 + canonical_domain: string;
59 + website: string;
60 + description: string | null;
61 + industries: string[];
62 + industry_primary: string | null;
63 + country: string | null;
64 + hq_city: string | null;
65 + hq_region: string | null;
66 + public_company: boolean;
67 + ticker: string | null;
68 + exchange: string | null;
69 + founded_year: number | null;
70 + employees_band: string | null;
71 + logo_url: string | null;
72 + status: string;
73 + onboarding_status: string;
74 + importance: number;
75 + tier: 1 | 2 | 3 | 4;
76 + metrics: Partial<Record<Metric, number>>;
77 + counts: CompanyCounts;
78 + last_event_at: string | null;
79 + last_observed_at: string | null;
80 + sparkline?: number[];
81 +}
82 +
83 +export interface MetricDetail {
84 + metric: Metric | string;
85 + value: number;
86 + confidence: number;
87 + computed_at: string;
88 + inputs: Record<string, unknown>;
89 + formula_version?: string;
90 +}
91 +
92 +export interface Relationship {
93 + kind: string;
94 + company: { slug: string; display_name: string } | null;
95 + to_name: string | null;
96 + valid_from: string | null;
97 + valid_to: string | null;
98 + confidence: number;
99 +}
100 +
101 +export interface CompanyDetail extends CompanyCard {
102 + aliases: string[];
103 + domains: { domain: string; kind: string }[];
104 + relationships: Relationship[];
105 + metrics_detail: MetricDetail[];
106 + sensors_by_surface: Record<string, number>;
107 + coverage: { historical_coverage: number | null; first_observed_at: string | null; days_observed: number; sensor_uptime: number | null };
108 + signals: Signal[];
109 + sparklines: { activity_30d: number[]; hiring_90d: number[] };
110 +}
111 +
112 +export interface EventSource {
113 + source_url: string;
114 + surface: string | null;
115 + detected_at: string;
116 + kind: string;
117 + sensor_id: string | null;
118 +}
119 +
120 +export type EventOrigin = 'deterministic' | 'llm' | 'hybrid' | 'backfill';
121 +export type EventStatus = 'active' | 'retracted' | 'duplicate' | 'review';
122 +
123 +export interface Event {
124 + id: string;
125 + company: CompanyRef;
126 + event_type: string;
127 + event_subtype: string;
128 + importance: number;
129 + confidence: number;
130 + confidence_label: ConfidenceLabel | string;
131 + title: string;
132 + summary: string | null;
133 + old_value: string | null;
134 + new_value: string | null;
135 + payload: Record<string, unknown>;
136 + entities: Record<string, unknown>;
137 + tags: string[];
138 + detected_at: string;
139 + effective_at: string | null;
140 + published_at: string | null;
141 + source_url: string | null;
142 + surface: string | null;
143 + sensor_id: string | null;
144 + change_id: string | null;
145 + cluster_id: string | null;
146 + origin: EventOrigin;
147 + model_name: string | null;
148 + prompt_version: string | null;
149 + status: EventStatus;
150 + sources?: EventSource[];
151 +}
152 +
153 +export interface EventDetail extends Event {
154 + sources: EventSource[];
155 + change: Change | null;
156 +}
157 +
158 +export type SensorTier = 'A' | 'B' | 'C' | 'D' | 'E';
159 +
160 +export interface Sensor {
161 + id: string;
162 + company_id: string;
163 + surface: string;
164 + connector_id: string;
165 + url: string;
166 + canonical_url: string;
167 + domain: string;
168 + status: string;
169 + tier: SensorTier;
170 + quality_score: number;
171 + discovery_confidence: number;
172 + discovery_method: string | null;
173 + current_interval_s: number;
174 + next_run_at: string;
175 + last_run_at: string | null;
176 + last_success_at: string | null;
177 + last_change_at: string | null;
178 + last_status: number | null;
179 + last_failure_class: string | null;
180 + consecutive_failures: number;
181 + observation_count: number;
182 + snapshot_count: number;
183 + change_count: number;
184 + meaningful_change_count: number;
185 + event_count: number;
186 + created_at: string;
187 +}
188 +
189 +export interface SensorDetail extends Sensor {
190 + company: CompanyRef;
191 + latest_snapshot: Snapshot | null;
192 +}
193 +
194 +export interface Snapshot {
195 + id: string;
196 + sensor_id: string;
197 + version_no: number;
198 + fetched_at: string;
199 + title: string | null;
200 + language: string | null;
201 + text_length: number | null;
202 + block_count: number | null;
203 + extracted_summary: Record<string, number>;
204 + content_hash: string;
205 + previous_snapshot_id: string | null;
206 +}
207 +
208 +export interface Block {
209 + key: string;
210 + kind: string;
211 + path: string;
212 + text: string;
213 +}
214 +
215 +export interface SnapshotDetail extends Snapshot {
216 + text: string;
217 + blocks: Block[];
218 + extracted: Record<string, unknown>;
219 +}
220 +
221 +export interface BlockDelta {
222 + key: string;
223 + kind: string;
224 + path: string;
225 + before: string | null;
226 + after: string | null;
227 + weight: number;
228 + similarity: number | null;
229 +}
230 +
231 +export interface DiffPayload {
232 + added: BlockDelta[];
233 + removed: BlockDelta[];
234 + modified: BlockDelta[];
235 + moved: string[];
236 + counts: Record<string, number>;
237 + text_delta_ratio: number;
238 + similarity: number;
239 + reasons: string[];
240 +}
241 +
242 +export interface Change {
243 + id: string;
244 + sensor_id: string;
245 + surface: string;
246 + company_id: string;
247 + detected_at: string;
248 + significance: number;
249 + kind: string;
250 + blocks_added: number;
251 + blocks_removed: number;
252 + blocks_modified: number;
253 + text_delta_ratio: number;
254 + similarity: number | null;
255 + snapshot_before: string | null;
256 + snapshot_after: string;
257 + diff?: DiffPayload;
258 + structured_delta?: Record<string, unknown>;
259 +}
260 +
261 +export interface ChangeDetail extends Change {
262 + diff: DiffPayload;
263 + structured_delta: Record<string, unknown>;
264 + events: Event[];
265 + company?: CompanyRef;
266 +}
267 +
268 +export interface SnapshotDiff {
269 + before: Snapshot;
270 + after: Snapshot;
271 + diff: DiffPayload;
272 +}
273 +
274 +export interface Job {
275 + id: string;
276 + title: string;
277 + department: string | null;
278 + location_text: string | null;
279 + city: string | null;
280 + country: string | null;
281 + remote: boolean | null;
282 + employment_type: string | null;
283 + seniority: string | null;
284 + url: string | null;
285 + posted_at: string | null;
286 + first_seen_at: string;
287 + last_seen_at: string;
288 + removed_at: string | null;
289 + status: 'open' | 'no_longer_listed';
290 + is_ai: boolean;
291 +}
292 +
293 +export interface JobsSummary {
294 + open: number;
295 + new_7d: number;
296 + removed_7d: number;
297 + ai_open: number;
298 + by_country: { country: string; n: number }[];
299 + by_department: { department: string; n: number }[];
300 + remote_ratio: number | null;
301 +}
302 +
303 +export interface JobsPage extends Page<Job> {
304 + meta?: { summary?: JobsSummary } & Record<string, unknown>;
305 + summary?: JobsSummary;
306 +}
307 +
308 +export interface Person {
309 + id: string;
310 + name: string;
311 + title: string | null;
312 + role_category: string | null;
313 + is_executive: boolean;
314 + first_seen_at: string;
315 + last_seen_at: string;
316 + removed_at: string | null;
317 + status: string;
318 + source_url: string | null;
319 +}
320 +
321 +export interface Product {
322 + id: string;
323 + name: string;
324 + category: string | null;
325 + description: string | null;
326 + url: string | null;
327 + first_seen_at: string;
328 + last_seen_at: string;
329 + removed_at: string | null;
330 + status: string;
331 +}
332 +
333 +export interface Plan {
334 + id: string;
335 + plan_name: string;
336 + price: number | null;
337 + price_text: string | null;
338 + currency: string | null;
339 + billing_period: string | null;
340 + unit: string | null;
341 + features: string[];
342 + contact_sales: boolean;
343 + version_no: number;
344 + valid_from: string;
345 + valid_to: string | null;
346 + status: string;
347 + source_url: string | null;
348 +}
349 +
350 +export interface Location {
351 + id: string;
352 + kind: string;
353 + name: string | null;
354 + city: string | null;
355 + region: string | null;
356 + country: string | null;
357 + lat: number | null;
358 + lon: number | null;
359 + first_seen_at: string;
360 + last_seen_at: string;
361 + removed_at: string | null;
362 + status: string;
363 + source_url: string | null;
364 +}
365 +
366 +export interface NewsItem {
367 + id: string;
368 + title: string;
369 + url: string;
370 + summary: string | null;
371 + category: string | null;
372 + published_at: string | null;
373 + first_seen_at: string;
374 + language: string | null;
375 +}
376 +
377 +export interface MetricPoint {
378 + day: string;
379 + value: number;
380 + confidence: number;
381 +}
382 +
383 +export interface Signal {
384 + id: string;
385 + company_id: string | null;
386 + scope: string;
387 + scope_key: string | null;
388 + kind: string;
389 + strength: number;
390 + confidence: number;
391 + title: string;
392 + explanation: string | null;
393 + evidence: Record<string, unknown>;
394 + window_days: number;
395 + detected_at: string;
396 + status: string;
397 +}
398 +
399 +// ---------------------------------------------------------------------------------------------------------- platform
400 +export interface Stats {
401 + companies: number;
402 + companies_active: number;
403 + sensors: number;
404 + sensors_active: number;
405 + observations: number;
406 + snapshots: number;
407 + changes: number;
408 + meaningful_changes: number;
409 + events: number;
410 + jobs_open: number;
411 + countries: number;
412 + industries: number;
413 + observations_today: number;
414 + changes_today: number;
415 + events_today: number;
416 + dataset_started_at: string | null;
417 + dataset_age_days: number | null;
418 + oldest_history_days: number | null;
419 + last_observation_at: string | null;
420 + archive: { objects: number; bytes: number };
421 +}
422 +
423 +export interface GlobalDaily {
424 + day: string;
425 + companies_active: number;
426 + sensors_active: number;
427 + observations: number;
428 + changes: number;
429 + meaningful_changes: number;
430 + events: number;
431 + events_by_type: Record<string, number>;
432 + jobs_open: number;
433 + jobs_new: number;
434 + jobs_removed: number;
435 + activity_index: number | null;
436 +}
437 +
438 +export interface SystemHealth {
439 + sensors_online: number;
440 + sensors_failing: number;
441 + observations_today: number;
442 + events_today: number;
443 + countries_covered: number;
444 + queue_lag_s: number | null;
445 + scheduler_last_tick_at: string | null;
446 + fetch_per_min: number | null;
447 + success_rate_24h: number | null;
448 +}
449 +
450 +export interface IndustryRow {
451 + slug: string;
452 + name: string;
453 + parent_slug: string | null;
454 + companies: number;
455 + events_7d: number;
456 + events_30d: number;
457 + hiring_momentum_30d: number | null;
458 + activity_score: number | null;
459 + ai_adoption: number | null;
460 + top_event_types: string[];
461 +}
462 +
463 +export interface IndustryDetail extends IndustryRow {
464 + description: string | null;
465 + companies_list?: CompanyCard[];
466 + events: Event[];
467 + hiring: { open: number; new_30d: number; removed_30d: number; momentum_30d: number | null };
468 + series: MetricPoint[];
469 + countries: { country: string; companies: number }[];
470 + trending: TrendRow[];
471 +}
472 +/** `/industries/{slug}` returns `companies: CompanyCard[]` while the row uses `companies: number` — see docs/FRONTEND.md. */
473 +export type IndustryDetailRaw = Omit<IndustryDetail, 'companies' | 'companies_list'> & { companies: CompanyCard[] | number };
474 +
475 +export interface CountryRow {
476 + code: string;
477 + name: string;
478 + region: string | null;
479 + companies: number;
480 + events_7d: number;
481 + events_30d: number;
482 + hiring_momentum_30d: number | null;
483 + activity_score: number | null;
484 + industry_mix: { industry: string; companies: number }[];
485 + lat: number | null;
486 + lon: number | null;
487 +}
488 +
489 +export interface CountryDetail extends CountryRow {
490 + companies_list?: CompanyCard[];
491 + events: Event[];
492 + movers: CompanyCard[];
493 + new_entrants: CompanyCard[];
494 + series: MetricPoint[];
495 + industries: IndustryRow[];
496 +}
497 +export type CountryDetailRaw = Omit<CountryDetail, 'companies' | 'companies_list'> & { companies: CompanyCard[] | number };
498 +
499 +export interface TrendRow {
500 + term: string;
501 + mentions: number;
502 + companies: number;
503 + momentum: number | null;
504 + series: number[];
505 +}
506 +
507 +export interface MapBucket {
508 + lat: number;
509 + lon: number;
510 + country: string;
511 + city: string | null;
512 + companies: number;
513 + events_30d: number;
514 + jobs_open: number;
515 + top: { slug: string; display_name: string }[];
516 +}
517 +
518 +export interface ActivityIndex {
519 + value: number | null;
520 + baseline: number;
521 + delta_7d: number | null;
522 + delta_30d: number | null;
523 + series: MetricPoint[];
524 + by_type: Record<string, number>;
525 + by_country: { key: string; value: number }[];
526 + by_industry: { key: string; value: number }[];
527 + formula_version: string;
528 +}
529 +
530 +export interface Pulse {
531 + stats: Stats;
532 + live: Event[];
533 + movers: CompanyCard[];
534 + hiring: CompanyCard[];
535 + launches: Event[];
536 + pricing: Event[];
537 + ai: CompanyCard[];
538 + industries: IndustryRow[];
539 + countries: CountryRow[];
540 + trending: TrendRow[];
541 + activity_index: { value: number | null; delta_7d: number | null; series: MetricPoint[] };
542 + map: MapBucket[];
543 +}
544 +
545 +export type RankingKind = 'most_active' | 'hiring_growth' | 'hiring_decline' | 'product_velocity' | 'ai_active' | 'geo_expansion' | 'developer_momentum' | 'pricing_changes' | 'unusual_activity';
546 +export type RankingWindow = '24h' | '7d' | '30d' | '90d' | '1y';
547 +
548 +export interface RankingItem extends CompanyCard {
549 + rank: number;
550 + value: number;
551 + delta: number | null;
552 +}
553 +export interface Rankings {
554 + kind: RankingKind | string;
555 + window: RankingWindow | string;
556 + items: RankingItem[];
557 +}
558 +
559 +export interface TimelinePayload {
560 + items: (Event & { day: string })[];
561 + days: { day: string; count: number }[];
562 +}
563 +
564 +export interface CompanyMetrics {
565 + current: MetricDetail[];
566 + series: Partial<Record<Metric, MetricPoint[]>>;
567 +}
568 +
569 +export interface ComparePayload {
570 + companies: CompanyCard[];
571 + metrics: Partial<Record<Metric, Record<string, number>>>;
572 + series: Record<string, MetricPoint[]>;
573 + events_30d: Record<string, Record<string, number>>;
574 + jobs: Record<string, { open: number; ai_open: number; new_30d: number }>;
575 + locations: Record<string, number>;
576 +}
577 +
578 +export interface HistoryPayload {
579 + sensors: (Sensor & { versions: Snapshot[] })[];
580 +}
581 +
582 +export interface EventTypes {
583 + types: { event_type: string; subtypes: { event_subtype: string; count_30d: number }[]; count_30d: number }[];
584 +}
585 +export interface EventSummary {
586 + items: { key: string; count: number; delta_pct: number | null }[];
587 +}
588 +
589 +export interface SearchPayload {
590 + query: string;
591 + companies: CompanyCard[];
592 + events: Event[];
593 + industries: IndustryRow[];
594 + countries: CountryRow[];
595 + people: (Person & { company: CompanyRef })[];
596 + products: (Product & { company: CompanyRef })[];
597 + took_ms: number;
598 +}
599 +export interface Suggestion {
600 + kind: 'company' | 'industry' | 'country' | 'event_type';
601 + label: string;
602 + sublabel: string | null;
603 + href: string;
604 +}
605 +export interface AskPayload {
606 + interpretation: Record<string, unknown>;
607 + answer: string;
608 + companies: CompanyCard[];
609 + events: Event[];
610 + sources: string[];
611 +}
612 +
613 +export interface WatchlistPayload {
614 + items: CompanyCard[];
615 + events: Event[];
616 +}
617 +export interface AlertCondition {
618 + event_types?: string[];
619 + min_importance?: number;
620 + metrics?: Partial<Record<string, { gt?: number; lt?: number }>>;
621 +}
622 +export interface Alert {
623 + id: string;
624 + name: string;
625 + company: string | null;
626 + condition: AlertCondition;
627 + channel: 'web' | 'webhook';
628 + target: string | null;
629 + created_at: string;
630 + status?: string;
631 +}
632 +export interface AlertDelivery {
633 + id: string;
634 + alert_id: string;
635 + alert_name?: string;
636 + event_id: string | null;
637 + event?: Event | null;
638 + channel: string;
639 + status: string;
640 + delivered_at: string;
641 +}
642 +
643 +export interface SitemapPayload {
644 + items: { slug: string; updated_at: string | null }[];
645 + pages: number;
646 +}
647 +
648 +export interface Methodology {
649 + metrics: { metric: string; formula_version: string; description: string; inputs: string[] }[];
650 + significance_bands: Record<string, [number, number]> | { label: string; min: number; max: number }[];
651 + event_types: string[];
652 + confidence_labels: Record<string, string> | string[];
653 +}
654 +
655 +// ---------------------------------------------------------------------------------------------------------- admin
656 +export interface AdminOverview {
657 + companies_by_status: Record<string, number>;
658 + sensors_by_status: Record<string, number>;
659 + sensors_by_tier: Record<string, number>;
660 + queue: { pending: number; running: number; dead: number; oldest_pending_s: number | null };
661 + llm: { pending: number; done_today: number; failed_today: number; budget_left: number | null };
662 + failures_24h_by_class: Record<string, number>;
663 + fetch_rate_1h: number | null;
664 + change_rate_1h: number | null;
665 + meaningful_rate_1h: number | null;
666 + storage: { objects: number; bytes: number };
667 + workers: { name: string; last_seen_at: string; inflight: number }[];
668 + cost_today: { fetch: number; browser: number; llm: number };
669 +}
670 +export interface AdminConnector {
671 + id: string;
672 + name: string;
673 + version: string;
674 + category: string;
675 + enabled: boolean;
676 + sensors_active: number;
677 + sensors_failing: number;
678 + success_rate_24h: number | null;
679 + avg_latency_ms: number | null;
680 + change_rate_24h: number | null;
681 + errors_24h: number;
682 + last_run_at: string | null;
683 +}
684 +export type AdminSensor = Sensor & { company: CompanyRef };
685 +export interface AdminFailure {
686 + id: string;
687 + sensor_id: string | null;
688 + company?: CompanyRef | null;
689 + domain: string | null;
690 + failure_class: string;
691 + status_code: number | null;
692 + message: string | null;
693 + occurred_at: string;
694 + retry_at: string | null;
695 +}
696 +export interface AdminQueueItem {
697 + id: string;
698 + kind: string;
699 + status: string;
700 + priority: number;
701 + attempts: number;
702 + scheduled_at: string;
703 + started_at: string | null;
704 + finished_at: string | null;
705 + worker: string | null;
706 + ref: string | null;
707 + error: string | null;
708 +}
709 +export interface AdminLlmJob {
710 + id: string;
711 + kind: string;
712 + status: string;
713 + model: string | null;
714 + prompt_version: string | null;
715 + change_id: string | null;
716 + event_id: string | null;
717 + tokens_in: number | null;
718 + tokens_out: number | null;
719 + cost_estimate: number | null;
720 + created_at: string;
721 + finished_at: string | null;
722 + error: string | null;
723 +}
724 +export interface AdminReview {
725 + id: string;
726 + kind: string;
727 + status: string;
728 + subject: string;
729 + ref_id: string | null;
730 + company?: CompanyRef | null;
731 + reason: string | null;
732 + created_at: string;
733 + resolved_at: string | null;
734 + resolution: string | null;
735 +}
736 +export interface AdminQuality {
737 + coverage: { companies_active_pct: number | null; sensors_active_pct: number | null };
738 + freshness: { sensors_checked_24h_pct: number | null; stale: number };
739 + duplicate_rate: number | null;
740 + event_confidence_avg: number | null;
741 + unknown_surfaces: number;
742 + failed_sensors: number;
743 + calibration: { correct: number; duplicate: number; noise: number; misclassified: number };
744 +}
745 +export interface AdminCosts {
746 + items: { day: string; dimension: string; key: string; units: number; cost_estimate: number }[];
747 + per_1000_companies: number | null;
748 + per_million_observations: number | null;
749 + per_meaningful_event: number | null;
750 +}
751 +export type AdminCompany = CompanyCard;
added apps/web/tsconfig.json +45 −0
@@ -0,0 +1,45 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": [
5 + "dom",
6 + "dom.iterable",
7 + "esnext"
8 + ],
9 + "allowJs": true,
10 + "skipLibCheck": true,
11 + "strict": true,
12 + "noUncheckedIndexedAccess": true,
13 + "noEmit": true,
14 + "esModuleInterop": true,
15 + "module": "esnext",
16 + "moduleResolution": "bundler",
17 + "resolveJsonModule": true,
18 + "isolatedModules": true,
19 + "jsx": "react-jsx",
20 + "incremental": true,
21 + "plugins": [
22 + {
23 + "name": "next"
24 + }
25 + ],
26 + "paths": {
27 + "@/*": [
28 + "./src/*"
29 + ]
30 + }
31 + },
32 + "include": [
33 + "next-env.d.ts",
34 + "**/*.ts",
35 + "**/*.tsx",
36 + ".next/types/**/*.ts",
37 + ".next/dev/types/**/*.ts",
38 + ".next-build/types/**/*.ts",
39 + ".next-build/dev/types/**/*.ts"
40 + ],
41 + "exclude": [
42 + "node_modules",
43 + "qa"
44 + ]
45 +}
added docs/FRONTEND.md +117 −0
@@ -0,0 +1,117 @@
1 +# Company Atlas — web app (`apps/web`)
2 +
3 +Next 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4, Geist self-hosted. SSR everywhere it makes sense
4 +(ISR 60–3600 s for public aggregates, `no-store` for live/owner/admin), client components only for the live pieces
5 +(SSE feed, counters, ⌘K search, drawer, watchlist, admin). Ports: dev **8370** → API **8371**; prod **8360** → API
6 +`127.0.0.1:8361` (`API_URL`). The browser never talks to FastAPI directly: `/api/v1/*` and `/health` are rewritten by
7 +`next.config.ts`.
8 +
9 +## Run
10 +
11 +```bash
12 +pnpm install # repo root
13 +node apps/web/qa/mock-api.mjs # contract-shaped mock API on :8371 (dev/QA only; admin token dev-admin-token)
14 +pnpm dev:web # http://localhost:8370 (reads ../../.env; API_URL defaults to :8371)
15 +pnpm -r typecheck && pnpm build # apps/web/.next (NEXT_DIST_DIR overrides the output dir)
16 +pnpm --filter @company-atlas/web start # :8360, expects API_URL
17 +# QA (Playwright from ~/Desktop/uqo-eval): screenshots in apps/web/qa/screens/ (git-ignored)
18 +node apps/web/qa/screens.mjs [BASE] [API] # every route × 390/1440 × dark/light; console errors, 404/5xx, overflow, tap targets, counters, SSE
19 +node apps/web/qa/flows.mjs [BASE] # live feed → drawer → company → timeline filter → compare → watchlist → ⌘K → admin
20 +```
21 +
22 +## Routes
23 +
24 +| Route | Rendering | Data |
25 +|---|---|---|
26 +| `/` | ISR 60 s + client live counters/feed | `/pulse`, `/live`, `/index`, `/signals`, `/stats` |
27 +| `/live` (`?event_type&min_importance&min_confidence&country&industry`) | dynamic + SSE `/live/stream` (polling fallback + 45 s watchdog) | `/live`, `/countries`, `/industries` |
28 +| `/companies` (filters, sort, search, pagination) | ISR 120 s | `/companies?sparkline=1` |
29 +| `/company/[slug]` (`?tab=overview|timeline|signals|jobs|products|pricing|locations|leadership|sources|history`, `&filter=`, `&status=&ai=1`) | ISR 120 s; only the active tab's data is fetched | `/companies/{slug}` + per-tab endpoints |
30 +| `/company/compare?companies=a,b,c` | ISR 120 s + client picker (`/search/suggest`) | `/companies/compare` |
31 +| `/events`, `/events/[id]` | ISR 60/120 s | `/events`, `/events/summary`, `/events/{id}` (+ `/companies/{slug}/events`) |
32 +| `/change/[id]`, `/sensor/[id]`, `/snapshot/[id]`, `/snapshot/[id]/diff/[other]` | ISR (noindex) | provenance endpoints |
33 +| `/rankings?kind&window&country&industry` | ISR 120 s | `/rankings` |
34 +| `/industry`, `/industry/[slug]`, `/country`, `/country/[code]` | ISR 300 s | atlases + `/map` |
35 +| `/search?q=` (+ `/ask` panel when the query reads like a question) | dynamic | `/search`, `/ask` |
36 +| `/watchlist` | client (owner token) | `/watchlist`, `/alerts`, `/alerts/deliveries` |
37 +| `/system`, `/methodology`, `/about`, `/api`, `/bot` | ISR / static | `/system`, `/stats`, `/stats/history`, `/methodology` |
38 +| `/admin`, `/admin/[module]` (overview, connectors, sensors, companies, failures, queue, llm, reviews, quality, costs) | client, token in localStorage → `X-CA-Admin-Token` | `/admin/*` |
39 +| `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` |
40 +
41 +Not-found: `not-found.tsx` (dynamic segments check existence in their `layout.tsx`/page and call `notFound()`); `error.tsx`
42 +re-applies the theme. No root `loading.tsx` (Next 16 soft-404 gotcha).
43 +
44 +## Code map
45 +
46 +```
47 +src/lib types.ts (docs/API.md mirror) · api.ts (server fetch, ApiError, safe/orNull) · client-api.ts (same-origin, owner/admin)
48 + format.ts · site.ts (names, nav, routes) · event-styles.ts (type → hue/label/subtype wording) · countries.ts (ISO)
49 + owner.ts (X-CA-Owner-Token + local watched mirror) · admin.ts / admin-modules.ts · params.ts · prepaint.ts · fonts.ts
50 +src/components layout/ (header, mobile tab bar, ⌘K dialog, theme, density, footer) · ui/ (section/stat/empty, badges, tabs, sheet,
51 + pagination, key-value, live dot/ago, skeleton) · charts/ (Sparkline, LineChart, Bars/Columns, Heatmap, WorldMap)
52 + events/ (row/list, drawer + context, evidence, filters, diff viewer) · live/ (SSE feed, counters)
53 + company/ (header + density strip, metric tiles, table/cards/mini list, tab panels, compare picker, watch button,
54 + watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (mark, logo)
55 +qa/ mock-api.mjs · screens.mjs · flows.mjs · screens/ (output, git-ignored)
56 +```
57 +
58 +Design tokens live at the top of `src/app/globals.css` (canvas/surface/ink/rule/accent/positive/warning/danger/live,
59 +per-event-type hues `--ev-*`, sensor tiers, series, map, brand plate, density variables) and are exposed to Tailwind through
60 +`@theme inline`. Both themes are hand-tuned; default follows the system and `lib/prepaint.ts` sets `data-theme` before paint.
61 +
62 +## Careful language, enforced in the UI
63 +
64 +`no_longer_listed` → "no longer listed"; job decreases say "listings are no longer visible … not evidence of layoffs";
65 +products "no longer listed in the public catalog"; leadership "no longer listed on the monitored leadership page"; signals
66 +carry a `signal` chip and confidence; every event shows `confidence_label`, `origin` (llm/hybrid show model + prompt version),
67 +sources with detection times and a link to `/change/[id]`; retracted events stay visible, struck through, and are excluded
68 +from counts. Missing metrics render as a dash with "not enough monitored evidence" — never 0. Empty states: "No monitored
69 +evidence available yet." / "Last successfully checked …".
70 +
71 +## QA results (2026-09-12, mock API)
72 +
73 +`qa/screens.mjs`: 178 checks (44 routes × 390/1440 × dark/light + counters + SSE) — all 200/404 as expected, zero console
74 +errors, zero 404/5xx sub-requests, zero horizontal overflow, zero interactive controls under 40 px on mobile, homepage
75 +counters within the `/stats` window, SSE prepends a new row on `/live`. `qa/flows.mjs`: 15/15 steps pass at 390 and 1440
76 +(live feed → drawer → company → timeline filter → compare picker → watch → watchlist + alert rule → ⌘K → admin).
77 +`pnpm -r typecheck` clean; `pnpm build` green (27 static pages + dynamic routes, sitemap shards prerendered). Screenshots reviewed by eye (home, live, company tabs,
78 +compare, events, event detail, change diff, sensor, snapshot diff, rankings, industry/country, search + ask, watchlist,
79 +system, admin, 404) in both themes.
80 +
81 +Fixed during QA: grid items letting scroll containers widen the page on mobile (`[class*='grid-cols'] > * { min-width: 0 }`);
82 +sub-40 px `.btn-sm`/chips/compare-remove buttons on touch; duplicate React keys (world-atlas features with repeated ids,
83 +duplicated event tags); OG image satori flex rule; `/sitemap.xml` conflict with `generateSitemaps` (shards moved under
84 +`/sitemaps/`); `ADMIN_MODULES` imported from a `'use client'` file into a server page; a `valueOf` prop name colliding with
85 +`Object.prototype`; hiring-momentum value wrapping in the narrow mobile metric tile; `useSearchParams` in the homepage live feed without a Suspense
86 +boundary (build-time prerender error); SSE frames buffered by gzip behind the Next proxy (see contract note 1).
87 +
88 +## Contract notes / questions for the API team
89 +
90 +1. **SSE must not be compressed.** Behind the Next rewrite proxy (dev and `next start`) `text/event-stream` is gzip-buffered
91 + unless the response carries `Cache-Control: no-store, no-transform`. Without it the browser receives nothing until the
92 + connection closes. The mock sets it; FastAPI's `/live/stream` must too (and any reverse proxy must keep
93 + `X-Accel-Buffering: no`). The web feed has a 45 s watchdog that falls back to polling `/live?since=` just in case.
94 +2. `/industries/{slug}` and `/countries/{code}` return `companies: CompanyCard[]` while the list rows use `companies: number`.
95 + The client handles both (`IndustryDetailRaw`/`CountryDetailRaw`); a distinct field name (e.g. `companies_list`) would be
96 + cleaner.
97 +3. `/companies/{slug}/jobs` — the summary is expected in `meta.summary` (the page also accepts a top-level `summary`).
98 +4. `/live` — the client accepts both `{ items: Event[] }` and a bare `Event[]`; please return `{ items }`.
99 +5. `/admin/queue` and `/admin/reviews` — documented as unpaginated `{ items }`; the client also accepts a `Page`.
100 +6. `Event.importance` is treated as 0–1 (values > 1 are read as 0–100). Please keep 0–1 as in the mock.
101 +7. `Event.tags` should be de-duplicated server-side (type + surface collide, e.g. `pricing`/`pricing`).
102 +8. `/watchlist` GET for a fresh token should return `200 { items: [], events: [] }` rather than 404 so first-visit renders
103 + cleanly (the mock does).
104 +9. Sitemap: the web asks `/sitemap?kind=companies&page=N` and expects `pages` on every response; only `indexed = true`
105 + companies should be listed.
106 +10. `MapBucket.country` is expected as ISO-3166 alpha-2 (the map maps world-atlas numeric ids → alpha-2 for hover/click).
107 +11. `CompanyDetail.coverage.historical_coverage` is displayed as a percentage (0–100), consistent with the `historical_coverage`
108 + metric.
109 +
110 +## Known gaps
111 +
112 +- No Technology tab yet (spec §40 lists it; the API has no technology-signals endpoint) — technology signals surface through
113 + `TECHNOLOGY` events and the developer/documentation surfaces.
114 +- Alerts are created/deleted but not edited; deliveries are read-only.
115 +- Admin sensor actions use `prompt()` for `set_interval`; `set_connector` has no UI yet.
116 +- `/api` documents rate limits as designed in the spec (120 req/min anonymous); confirm the real values once `ratelimit.py` lands.
117 +- The world map uses headquarters clusters from `/map`; per-office expansions will appear once buckets include them.
added pnpm-lock.yaml +1240 −0
@@ -0,0 +1,1240 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .: {}
10 +
11 + apps/web:
12 + dependencies:
13 + d3-array:
14 + specifier: ^3.2.4
15 + version: 3.2.4
16 + d3-geo:
17 + specifier: ^3.1.1
18 + version: 3.1.1
19 + d3-scale:
20 + specifier: ^4.0.2
21 + version: 4.0.2
22 + d3-shape:
23 + specifier: ^3.2.0
24 + version: 3.2.0
25 + geist:
26 + specifier: ^1.5.1
27 + version: 1.7.2(next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))
28 + lucide-react:
29 + specifier: ^1.0.0
30 + version: 1.44.0(react@19.2.8)
31 + next:
32 + specifier: 16.3.4
33 + version: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
34 + react:
35 + specifier: 19.2.8
36 + version: 19.2.8
37 + react-dom:
38 + specifier: 19.2.8
39 + version: 19.2.8(react@19.2.8)
40 + server-only:
41 + specifier: ^0.0.1
42 + version: 0.0.1
43 + topojson-client:
44 + specifier: ^3.1.0
45 + version: 3.1.0
46 + world-atlas:
47 + specifier: ^2.0.2
48 + version: 2.0.2
49 + devDependencies:
50 + '@tailwindcss/postcss':
51 + specifier: ^4
52 + version: 4.3.3
53 + '@types/d3-array':
54 + specifier: ^3.2.1
55 + version: 3.2.2
56 + '@types/d3-geo':
57 + specifier: ^3.1.0
58 + version: 3.1.1
59 + '@types/d3-scale':
60 + specifier: ^4.0.9
61 + version: 4.0.9
62 + '@types/d3-shape':
63 + specifier: ^3.1.7
64 + version: 3.2.0
65 + '@types/node':
66 + specifier: ^24.0.0
67 + version: 24.13.4
68 + '@types/react':
69 + specifier: ^19
70 + version: 19.3.0
71 + '@types/react-dom':
72 + specifier: ^19
73 + version: 19.3.0(@types/react@19.3.0)
74 + '@types/topojson-client':
75 + specifier: ^3.1.5
76 + version: 3.1.5
77 + '@types/topojson-specification':
78 + specifier: ^1.0.5
79 + version: 1.0.5
80 + tailwindcss:
81 + specifier: ^4
82 + version: 4.3.3
83 + typescript:
84 + specifier: ^5.9.3
85 + version: 5.9.3
86 +
87 +packages:
88 +
89 + '@alloc/quick-lru@5.3.0':
90 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
91 + engines: {node: '>=10'}
92 +
93 + '@emnapi/runtime@1.11.3':
94 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
95 +
96 + '@img/colour@1.1.0':
97 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
98 + engines: {node: '>=18'}
99 +
100 + '@img/sharp-darwin-arm64@0.35.4':
101 + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
102 + engines: {node: '>=20.9.0'}
103 + cpu: [arm64]
104 + os: [darwin]
105 +
106 + '@img/sharp-darwin-x64@0.35.4':
107 + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
108 + engines: {node: '>=20.9.0'}
109 + cpu: [x64]
110 + os: [darwin]
111 +
112 + '@img/sharp-freebsd-wasm32@0.35.4':
113 + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
114 + engines: {node: '>=20.9.0'}
115 + os: [freebsd]
116 +
117 + '@img/sharp-libvips-darwin-arm64@1.3.3':
118 + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
119 + cpu: [arm64]
120 + os: [darwin]
121 +
122 + '@img/sharp-libvips-darwin-x64@1.3.3':
123 + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
124 + cpu: [x64]
125 + os: [darwin]
126 +
127 + '@img/sharp-libvips-linux-arm64@1.3.3':
128 + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
129 + cpu: [arm64]
130 + os: [linux]
131 + libc: [glibc]
132 +
133 + '@img/sharp-libvips-linux-arm@1.3.3':
134 + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
135 + cpu: [arm]
136 + os: [linux]
137 + libc: [glibc]
138 +
139 + '@img/sharp-libvips-linux-ppc64@1.3.3':
140 + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
141 + cpu: [ppc64]
142 + os: [linux]
143 + libc: [glibc]
144 +
145 + '@img/sharp-libvips-linux-riscv64@1.3.3':
146 + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
147 + cpu: [riscv64]
148 + os: [linux]
149 + libc: [glibc]
150 +
151 + '@img/sharp-libvips-linux-s390x@1.3.3':
152 + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
153 + cpu: [s390x]
154 + os: [linux]
155 + libc: [glibc]
156 +
157 + '@img/sharp-libvips-linux-x64@1.3.3':
158 + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
159 + cpu: [x64]
160 + os: [linux]
161 + libc: [glibc]
162 +
163 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
164 + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
165 + cpu: [arm64]
166 + os: [linux]
167 + libc: [musl]
168 +
169 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
170 + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
171 + cpu: [x64]
172 + os: [linux]
173 + libc: [musl]
174 +
175 + '@img/sharp-linux-arm64@0.35.4':
176 + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
177 + engines: {node: '>=20.9.0'}
178 + cpu: [arm64]
179 + os: [linux]
180 + libc: [glibc]
181 +
182 + '@img/sharp-linux-arm@0.35.4':
183 + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
184 + engines: {node: '>=20.9.0'}
185 + cpu: [arm]
186 + os: [linux]
187 + libc: [glibc]
188 +
189 + '@img/sharp-linux-ppc64@0.35.4':
190 + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
191 + engines: {node: '>=20.9.0'}
192 + cpu: [ppc64]
193 + os: [linux]
194 + libc: [glibc]
195 +
196 + '@img/sharp-linux-riscv64@0.35.4':
197 + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
198 + engines: {node: '>=20.9.0'}
199 + cpu: [riscv64]
200 + os: [linux]
201 + libc: [glibc]
202 +
203 + '@img/sharp-linux-s390x@0.35.4':
204 + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
205 + engines: {node: '>=20.9.0'}
206 + cpu: [s390x]
207 + os: [linux]
208 + libc: [glibc]
209 +
210 + '@img/sharp-linux-x64@0.35.4':
211 + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
212 + engines: {node: '>=20.9.0'}
213 + cpu: [x64]
214 + os: [linux]
215 + libc: [glibc]
216 +
217 + '@img/sharp-linuxmusl-arm64@0.35.4':
218 + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
219 + engines: {node: '>=20.9.0'}
220 + cpu: [arm64]
221 + os: [linux]
222 + libc: [musl]
223 +
224 + '@img/sharp-linuxmusl-x64@0.35.4':
225 + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
226 + engines: {node: '>=20.9.0'}
227 + cpu: [x64]
228 + os: [linux]
229 + libc: [musl]
230 +
231 + '@img/sharp-wasm32@0.35.4':
232 + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
233 + engines: {node: '>=20.9.0'}
234 +
235 + '@img/sharp-webcontainers-wasm32@0.35.4':
236 + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
237 + engines: {node: '>=20.9.0'}
238 + cpu: [wasm32]
239 +
240 + '@img/sharp-win32-arm64@0.35.4':
241 + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
242 + engines: {node: '>=20.9.0'}
243 + cpu: [arm64]
244 + os: [win32]
245 +
246 + '@img/sharp-win32-ia32@0.35.4':
247 + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
248 + engines: {node: ^20.9.0}
249 + cpu: [ia32]
250 + os: [win32]
251 +
252 + '@img/sharp-win32-x64@0.35.4':
253 + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
254 + engines: {node: '>=20.9.0'}
255 + cpu: [x64]
256 + os: [win32]
257 +
258 + '@jridgewell/gen-mapping@0.3.13':
259 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
260 +
261 + '@jridgewell/remapping@2.3.5':
262 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
263 +
264 + '@jridgewell/resolve-uri@3.1.2':
265 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
266 + engines: {node: '>=6.0.0'}
267 +
268 + '@jridgewell/sourcemap-codec@1.6.0':
269 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
270 +
271 + '@jridgewell/trace-mapping@0.3.31':
272 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
273 +
274 + '@next/env@16.3.4':
275 + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
276 +
277 + '@next/swc-darwin-arm64@16.3.4':
278 + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
279 + engines: {node: '>= 10'}
280 + cpu: [arm64]
281 + os: [darwin]
282 +
283 + '@next/swc-darwin-x64@16.3.4':
284 + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
285 + engines: {node: '>= 10'}
286 + cpu: [x64]
287 + os: [darwin]
288 +
289 + '@next/swc-linux-arm64-gnu@16.3.4':
290 + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
291 + engines: {node: '>= 10'}
292 + cpu: [arm64]
293 + os: [linux]
294 + libc: [glibc]
295 +
296 + '@next/swc-linux-arm64-musl@16.3.4':
297 + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
298 + engines: {node: '>= 10'}
299 + cpu: [arm64]
300 + os: [linux]
301 + libc: [musl]
302 +
303 + '@next/swc-linux-x64-gnu@16.3.4':
304 + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
305 + engines: {node: '>= 10'}
306 + cpu: [x64]
307 + os: [linux]
308 + libc: [glibc]
309 +
310 + '@next/swc-linux-x64-musl@16.3.4':
311 + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
312 + engines: {node: '>= 10'}
313 + cpu: [x64]
314 + os: [linux]
315 + libc: [musl]
316 +
317 + '@next/swc-win32-arm64-msvc@16.3.4':
318 + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
319 + engines: {node: '>= 10'}
320 + cpu: [arm64]
321 + os: [win32]
322 +
323 + '@next/swc-win32-x64-msvc@16.3.4':
324 + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
325 + engines: {node: '>= 10'}
326 + cpu: [x64]
327 + os: [win32]
328 +
329 + '@swc/helpers@0.5.23':
330 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
331 +
332 + '@tailwindcss/node@4.3.3':
333 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
334 +
335 + '@tailwindcss/oxide-android-arm64@4.3.3':
336 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
337 + engines: {node: '>= 20'}
338 + cpu: [arm64]
339 + os: [android]
340 +
341 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
342 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
343 + engines: {node: '>= 20'}
344 + cpu: [arm64]
345 + os: [darwin]
346 +
347 + '@tailwindcss/oxide-darwin-x64@4.3.3':
348 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
349 + engines: {node: '>= 20'}
350 + cpu: [x64]
351 + os: [darwin]
352 +
353 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
354 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
355 + engines: {node: '>= 20'}
356 + cpu: [x64]
357 + os: [freebsd]
358 +
359 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
360 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
361 + engines: {node: '>= 20'}
362 + cpu: [arm]
363 + os: [linux]
364 +
365 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
366 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
367 + engines: {node: '>= 20'}
368 + cpu: [arm64]
369 + os: [linux]
370 + libc: [glibc]
371 +
372 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
373 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
374 + engines: {node: '>= 20'}
375 + cpu: [arm64]
376 + os: [linux]
377 + libc: [musl]
378 +
379 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
380 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
381 + engines: {node: '>= 20'}
382 + cpu: [x64]
383 + os: [linux]
384 + libc: [glibc]
385 +
386 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
387 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
388 + engines: {node: '>= 20'}
389 + cpu: [x64]
390 + os: [linux]
391 + libc: [musl]
392 +
393 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
394 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
395 + engines: {node: '>=14.0.0'}
396 + cpu: [wasm32]
397 + bundledDependencies:
398 + - '@napi-rs/wasm-runtime'
399 + - '@emnapi/core'
400 + - '@emnapi/runtime'
401 + - '@tybys/wasm-util'
402 + - '@emnapi/wasi-threads'
403 + - tslib
404 +
405 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
406 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
407 + engines: {node: '>= 20'}
408 + cpu: [arm64]
409 + os: [win32]
410 +
411 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
412 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
413 + engines: {node: '>= 20'}
414 + cpu: [x64]
415 + os: [win32]
416 +
417 + '@tailwindcss/oxide@4.3.3':
418 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
419 + engines: {node: '>= 20'}
420 +
421 + '@tailwindcss/postcss@4.3.3':
422 + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
423 +
424 + '@types/d3-array@3.2.2':
425 + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
426 +
427 + '@types/d3-geo@3.1.1':
428 + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
429 +
430 + '@types/d3-path@3.1.1':
431 + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
432 +
433 + '@types/d3-scale@4.0.9':
434 + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
435 +
436 + '@types/d3-shape@3.2.0':
437 + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
438 +
439 + '@types/d3-time@3.0.4':
440 + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
441 +
442 + '@types/geojson@7946.0.16':
443 + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
444 +
445 + '@types/node@24.13.4':
446 + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
447 +
448 + '@types/react-dom@19.3.0':
449 + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
450 + peerDependencies:
451 + '@types/react': ^19.3.0
452 +
453 + '@types/react@19.3.0':
454 + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
455 +
456 + '@types/topojson-client@3.1.5':
457 + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
458 +
459 + '@types/topojson-specification@1.0.5':
460 + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
461 +
462 + baseline-browser-mapping@2.11.22:
463 + resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==}
464 + engines: {node: '>=6.0.0'}
465 + hasBin: true
466 +
467 + caniuse-lite@1.0.30001810:
468 + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
469 +
470 + client-only@0.0.1:
471 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
472 +
473 + commander@2.20.3:
474 + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
475 +
476 + csstype@3.2.3:
477 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
478 +
479 + d3-array@3.2.4:
480 + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
481 + engines: {node: '>=12'}
482 +
483 + d3-color@3.1.0:
484 + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
485 + engines: {node: '>=12'}
486 +
487 + d3-format@3.1.2:
488 + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
489 + engines: {node: '>=12'}
490 +
491 + d3-geo@3.1.1:
492 + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
493 + engines: {node: '>=12'}
494 +
495 + d3-interpolate@3.0.1:
496 + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
497 + engines: {node: '>=12'}
498 +
499 + d3-path@3.1.0:
500 + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
501 + engines: {node: '>=12'}
502 +
503 + d3-scale@4.0.2:
504 + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
505 + engines: {node: '>=12'}
506 +
507 + d3-shape@3.2.0:
508 + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
509 + engines: {node: '>=12'}
510 +
511 + d3-time-format@4.1.0:
512 + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
513 + engines: {node: '>=12'}
514 +
515 + d3-time@3.1.0:
516 + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
517 + engines: {node: '>=12'}
518 +
519 + detect-libc@2.1.2:
520 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
521 + engines: {node: '>=8'}
522 +
523 + enhanced-resolve@5.24.5:
524 + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
525 + engines: {node: '>=10.13.0'}
526 +
527 + geist@1.7.2:
528 + resolution: {integrity: sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==}
529 + peerDependencies:
530 + next: '>=13.2.0'
531 +
532 + graceful-fs@4.2.11:
533 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
534 +
535 + internmap@2.0.3:
536 + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
537 + engines: {node: '>=12'}
538 +
539 + jiti@2.7.0:
540 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
541 + hasBin: true
542 +
543 + lightningcss-android-arm64@1.32.0:
544 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
545 + engines: {node: '>= 12.0.0'}
546 + cpu: [arm64]
547 + os: [android]
548 +
549 + lightningcss-darwin-arm64@1.32.0:
550 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
551 + engines: {node: '>= 12.0.0'}
552 + cpu: [arm64]
553 + os: [darwin]
554 +
555 + lightningcss-darwin-x64@1.32.0:
556 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
557 + engines: {node: '>= 12.0.0'}
558 + cpu: [x64]
559 + os: [darwin]
560 +
561 + lightningcss-freebsd-x64@1.32.0:
562 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
563 + engines: {node: '>= 12.0.0'}
564 + cpu: [x64]
565 + os: [freebsd]
566 +
567 + lightningcss-linux-arm-gnueabihf@1.32.0:
568 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
569 + engines: {node: '>= 12.0.0'}
570 + cpu: [arm]
571 + os: [linux]
572 +
573 + lightningcss-linux-arm64-gnu@1.32.0:
574 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
575 + engines: {node: '>= 12.0.0'}
576 + cpu: [arm64]
577 + os: [linux]
578 + libc: [glibc]
579 +
580 + lightningcss-linux-arm64-musl@1.32.0:
581 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
582 + engines: {node: '>= 12.0.0'}
583 + cpu: [arm64]
584 + os: [linux]
585 + libc: [musl]
586 +
587 + lightningcss-linux-x64-gnu@1.32.0:
588 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
589 + engines: {node: '>= 12.0.0'}
590 + cpu: [x64]
591 + os: [linux]
592 + libc: [glibc]
593 +
594 + lightningcss-linux-x64-musl@1.32.0:
595 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
596 + engines: {node: '>= 12.0.0'}
597 + cpu: [x64]
598 + os: [linux]
599 + libc: [musl]
600 +
601 + lightningcss-win32-arm64-msvc@1.32.0:
602 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
603 + engines: {node: '>= 12.0.0'}
604 + cpu: [arm64]
605 + os: [win32]
606 +
607 + lightningcss-win32-x64-msvc@1.32.0:
608 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
609 + engines: {node: '>= 12.0.0'}
610 + cpu: [x64]
611 + os: [win32]
612 +
613 + lightningcss@1.32.0:
614 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
615 + engines: {node: '>= 12.0.0'}
616 +
617 + lucide-react@1.44.0:
618 + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==}
619 + peerDependencies:
620 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
621 +
622 + magic-string@0.30.21:
623 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
624 +
625 + nanoid@3.3.19:
626 + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
627 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
628 + hasBin: true
629 +
630 + next@16.3.4:
631 + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
632 + engines: {node: '>=20.9.0'}
633 + hasBin: true
634 + peerDependencies:
635 + '@opentelemetry/api': ^1.1.0
636 + '@playwright/test': ^1.51.1
637 + babel-plugin-react-compiler: '*'
638 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
639 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
640 + sass: ^1.3.0
641 + peerDependenciesMeta:
642 + '@opentelemetry/api':
643 + optional: true
644 + '@playwright/test':
645 + optional: true
646 + babel-plugin-react-compiler:
647 + optional: true
648 + sass:
649 + optional: true
650 +
651 + picocolors@1.1.1:
652 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
653 +
654 + postcss@8.5.23:
655 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
656 + engines: {node: ^10 || ^12 || >=14}
657 +
658 + postcss@8.5.28:
659 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
660 + engines: {node: ^10 || ^12 || >=14}
661 +
662 + react-dom@19.2.8:
663 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
664 + peerDependencies:
665 + react: ^19.2.8
666 +
667 + react@19.2.8:
668 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
669 + engines: {node: '>=0.10.0'}
670 +
671 + scheduler@0.27.0:
672 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
673 +
674 + semver@7.8.5:
675 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
676 + engines: {node: '>=10'}
677 + hasBin: true
678 +
679 + server-only@0.0.1:
680 + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
681 +
682 + sharp@0.35.4:
683 + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
684 + engines: {node: '>=20.9.0'}
685 + peerDependencies:
686 + '@types/node': '*'
687 + peerDependenciesMeta:
688 + '@types/node':
689 + optional: true
690 +
691 + source-map-js@1.2.1:
692 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
693 + engines: {node: '>=0.10.0'}
694 +
695 + styled-jsx@5.1.6:
696 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
697 + engines: {node: '>= 12.0.0'}
698 + peerDependencies:
699 + '@babel/core': '*'
700 + babel-plugin-macros: '*'
701 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
702 + peerDependenciesMeta:
703 + '@babel/core':
704 + optional: true
705 + babel-plugin-macros:
706 + optional: true
707 +
708 + tailwindcss@4.3.3:
709 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
710 +
711 + tapable@2.3.3:
712 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
713 + engines: {node: '>=6'}
714 +
715 + topojson-client@3.1.0:
716 + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
717 + hasBin: true
718 +
719 + tslib@2.8.1:
720 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
721 +
722 + typescript@5.9.3:
723 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
724 + engines: {node: '>=14.17'}
725 + hasBin: true
726 +
727 + undici-types@7.18.2:
728 + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
729 +
730 + world-atlas@2.0.2:
731 + resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
732 +
733 +snapshots:
734 +
735 + '@alloc/quick-lru@5.3.0': {}
736 +
737 + '@emnapi/runtime@1.11.3':
738 + dependencies:
739 + tslib: 2.8.1
740 + optional: true
741 +
742 + '@img/colour@1.1.0':
743 + optional: true
744 +
745 + '@img/sharp-darwin-arm64@0.35.4':
746 + optionalDependencies:
747 + '@img/sharp-libvips-darwin-arm64': 1.3.3
748 + optional: true
749 +
750 + '@img/sharp-darwin-x64@0.35.4':
751 + optionalDependencies:
752 + '@img/sharp-libvips-darwin-x64': 1.3.3
753 + optional: true
754 +
755 + '@img/sharp-freebsd-wasm32@0.35.4':
756 + dependencies:
757 + '@img/sharp-wasm32': 0.35.4
758 + optional: true
759 +
760 + '@img/sharp-libvips-darwin-arm64@1.3.3':
761 + optional: true
762 +
763 + '@img/sharp-libvips-darwin-x64@1.3.3':
764 + optional: true
765 +
766 + '@img/sharp-libvips-linux-arm64@1.3.3':
767 + optional: true
768 +
769 + '@img/sharp-libvips-linux-arm@1.3.3':
770 + optional: true
771 +
772 + '@img/sharp-libvips-linux-ppc64@1.3.3':
773 + optional: true
774 +
775 + '@img/sharp-libvips-linux-riscv64@1.3.3':
776 + optional: true
777 +
778 + '@img/sharp-libvips-linux-s390x@1.3.3':
779 + optional: true
780 +
781 + '@img/sharp-libvips-linux-x64@1.3.3':
782 + optional: true
783 +
784 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
785 + optional: true
786 +
787 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
788 + optional: true
789 +
790 + '@img/sharp-linux-arm64@0.35.4':
791 + optionalDependencies:
792 + '@img/sharp-libvips-linux-arm64': 1.3.3
793 + optional: true
794 +
795 + '@img/sharp-linux-arm@0.35.4':
796 + optionalDependencies:
797 + '@img/sharp-libvips-linux-arm': 1.3.3
798 + optional: true
799 +
800 + '@img/sharp-linux-ppc64@0.35.4':
801 + optionalDependencies:
802 + '@img/sharp-libvips-linux-ppc64': 1.3.3
803 + optional: true
804 +
805 + '@img/sharp-linux-riscv64@0.35.4':
806 + optionalDependencies:
807 + '@img/sharp-libvips-linux-riscv64': 1.3.3
808 + optional: true
809 +
810 + '@img/sharp-linux-s390x@0.35.4':
811 + optionalDependencies:
812 + '@img/sharp-libvips-linux-s390x': 1.3.3
813 + optional: true
814 +
815 + '@img/sharp-linux-x64@0.35.4':
816 + optionalDependencies:
817 + '@img/sharp-libvips-linux-x64': 1.3.3
818 + optional: true
819 +
820 + '@img/sharp-linuxmusl-arm64@0.35.4':
821 + optionalDependencies:
822 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
823 + optional: true
824 +
825 + '@img/sharp-linuxmusl-x64@0.35.4':
826 + optionalDependencies:
827 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
828 + optional: true
829 +
830 + '@img/sharp-wasm32@0.35.4':
831 + dependencies:
832 + '@emnapi/runtime': 1.11.3
833 + optional: true
834 +
835 + '@img/sharp-webcontainers-wasm32@0.35.4':
836 + dependencies:
837 + '@img/sharp-wasm32': 0.35.4
838 + optional: true
839 +
840 + '@img/sharp-win32-arm64@0.35.4':
841 + optional: true
842 +
843 + '@img/sharp-win32-ia32@0.35.4':
844 + optional: true
845 +
846 + '@img/sharp-win32-x64@0.35.4':
847 + optional: true
848 +
849 + '@jridgewell/gen-mapping@0.3.13':
850 + dependencies:
851 + '@jridgewell/sourcemap-codec': 1.6.0
852 + '@jridgewell/trace-mapping': 0.3.31
853 +
854 + '@jridgewell/remapping@2.3.5':
855 + dependencies:
856 + '@jridgewell/gen-mapping': 0.3.13
857 + '@jridgewell/trace-mapping': 0.3.31
858 +
859 + '@jridgewell/resolve-uri@3.1.2': {}
860 +
861 + '@jridgewell/sourcemap-codec@1.6.0': {}
862 +
863 + '@jridgewell/trace-mapping@0.3.31':
864 + dependencies:
865 + '@jridgewell/resolve-uri': 3.1.2
866 + '@jridgewell/sourcemap-codec': 1.6.0
867 +
868 + '@next/env@16.3.4': {}
869 +
870 + '@next/swc-darwin-arm64@16.3.4':
871 + optional: true
872 +
873 + '@next/swc-darwin-x64@16.3.4':
874 + optional: true
875 +
876 + '@next/swc-linux-arm64-gnu@16.3.4':
877 + optional: true
878 +
879 + '@next/swc-linux-arm64-musl@16.3.4':
880 + optional: true
881 +
882 + '@next/swc-linux-x64-gnu@16.3.4':
883 + optional: true
884 +
885 + '@next/swc-linux-x64-musl@16.3.4':
886 + optional: true
887 +
888 + '@next/swc-win32-arm64-msvc@16.3.4':
889 + optional: true
890 +
891 + '@next/swc-win32-x64-msvc@16.3.4':
892 + optional: true
893 +
894 + '@swc/helpers@0.5.23':
895 + dependencies:
896 + tslib: 2.8.1
897 +
898 + '@tailwindcss/node@4.3.3':
899 + dependencies:
900 + '@jridgewell/remapping': 2.3.5
901 + enhanced-resolve: 5.24.5
902 + jiti: 2.7.0
903 + lightningcss: 1.32.0
904 + magic-string: 0.30.21
905 + source-map-js: 1.2.1
906 + tailwindcss: 4.3.3
907 +
908 + '@tailwindcss/oxide-android-arm64@4.3.3':
909 + optional: true
910 +
911 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
912 + optional: true
913 +
914 + '@tailwindcss/oxide-darwin-x64@4.3.3':
915 + optional: true
916 +
917 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
918 + optional: true
919 +
920 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
921 + optional: true
922 +
923 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
924 + optional: true
925 +
926 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
927 + optional: true
928 +
929 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
930 + optional: true
931 +
932 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
933 + optional: true
934 +
935 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
936 + optional: true
937 +
938 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
939 + optional: true
940 +
941 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
942 + optional: true
943 +
944 + '@tailwindcss/oxide@4.3.3':
945 + optionalDependencies:
946 + '@tailwindcss/oxide-android-arm64': 4.3.3
947 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
948 + '@tailwindcss/oxide-darwin-x64': 4.3.3
949 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
950 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
951 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
952 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
953 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
954 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
955 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
956 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
957 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
958 +
959 + '@tailwindcss/postcss@4.3.3':
960 + dependencies:
961 + '@alloc/quick-lru': 5.3.0
962 + '@tailwindcss/node': 4.3.3
963 + '@tailwindcss/oxide': 4.3.3
964 + postcss: 8.5.28
965 + tailwindcss: 4.3.3
966 +
967 + '@types/d3-array@3.2.2': {}
968 +
969 + '@types/d3-geo@3.1.1':
970 + dependencies:
971 + '@types/geojson': 7946.0.16
972 +
973 + '@types/d3-path@3.1.1': {}
974 +
975 + '@types/d3-scale@4.0.9':
976 + dependencies:
977 + '@types/d3-time': 3.0.4
978 +
979 + '@types/d3-shape@3.2.0':
980 + dependencies:
981 + '@types/d3-path': 3.1.1
982 +
983 + '@types/d3-time@3.0.4': {}
984 +
985 + '@types/geojson@7946.0.16': {}
986 +
987 + '@types/node@24.13.4':
988 + dependencies:
989 + undici-types: 7.18.2
990 +
991 + '@types/react-dom@19.3.0(@types/react@19.3.0)':
992 + dependencies:
993 + '@types/react': 19.3.0
994 +
995 + '@types/react@19.3.0':
996 + dependencies:
997 + csstype: 3.2.3
998 +
999 + '@types/topojson-client@3.1.5':
1000 + dependencies:
1001 + '@types/geojson': 7946.0.16
1002 + '@types/topojson-specification': 1.0.5
1003 +
1004 + '@types/topojson-specification@1.0.5':
1005 + dependencies:
1006 + '@types/geojson': 7946.0.16
1007 +
1008 + baseline-browser-mapping@2.11.22: {}
1009 +
1010 + caniuse-lite@1.0.30001810: {}
1011 +
1012 + client-only@0.0.1: {}
1013 +
1014 + commander@2.20.3: {}
1015 +
1016 + csstype@3.2.3: {}
1017 +
1018 + d3-array@3.2.4:
1019 + dependencies:
1020 + internmap: 2.0.3
1021 +
1022 + d3-color@3.1.0: {}
1023 +
1024 + d3-format@3.1.2: {}
1025 +
1026 + d3-geo@3.1.1:
1027 + dependencies:
1028 + d3-array: 3.2.4
1029 +
1030 + d3-interpolate@3.0.1:
1031 + dependencies:
1032 + d3-color: 3.1.0
1033 +
1034 + d3-path@3.1.0: {}
1035 +
1036 + d3-scale@4.0.2:
1037 + dependencies:
1038 + d3-array: 3.2.4
1039 + d3-format: 3.1.2
1040 + d3-interpolate: 3.0.1
1041 + d3-time: 3.1.0
1042 + d3-time-format: 4.1.0
1043 +
1044 + d3-shape@3.2.0:
1045 + dependencies:
1046 + d3-path: 3.1.0
1047 +
1048 + d3-time-format@4.1.0:
1049 + dependencies:
1050 + d3-time: 3.1.0
1051 +
1052 + d3-time@3.1.0:
1053 + dependencies:
1054 + d3-array: 3.2.4
1055 +
1056 + detect-libc@2.1.2: {}
1057 +
1058 + enhanced-resolve@5.24.5:
1059 + dependencies:
1060 + graceful-fs: 4.2.11
1061 + tapable: 2.3.3
1062 +
1063 + geist@1.7.2(next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)):
1064 + dependencies:
1065 + next: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
1066 +
1067 + graceful-fs@4.2.11: {}
1068 +
1069 + internmap@2.0.3: {}
1070 +
1071 + jiti@2.7.0: {}
1072 +
1073 + lightningcss-android-arm64@1.32.0:
1074 + optional: true
1075 +
1076 + lightningcss-darwin-arm64@1.32.0:
1077 + optional: true
1078 +
1079 + lightningcss-darwin-x64@1.32.0:
1080 + optional: true
1081 +
1082 + lightningcss-freebsd-x64@1.32.0:
1083 + optional: true
1084 +
1085 + lightningcss-linux-arm-gnueabihf@1.32.0:
1086 + optional: true
1087 +
1088 + lightningcss-linux-arm64-gnu@1.32.0:
1089 + optional: true
1090 +
1091 + lightningcss-linux-arm64-musl@1.32.0:
1092 + optional: true
1093 +
1094 + lightningcss-linux-x64-gnu@1.32.0:
1095 + optional: true
1096 +
1097 + lightningcss-linux-x64-musl@1.32.0:
1098 + optional: true
1099 +
1100 + lightningcss-win32-arm64-msvc@1.32.0:
1101 + optional: true
1102 +
1103 + lightningcss-win32-x64-msvc@1.32.0:
1104 + optional: true
1105 +
1106 + lightningcss@1.32.0:
1107 + dependencies:
1108 + detect-libc: 2.1.2
1109 + optionalDependencies:
1110 + lightningcss-android-arm64: 1.32.0
1111 + lightningcss-darwin-arm64: 1.32.0
1112 + lightningcss-darwin-x64: 1.32.0
1113 + lightningcss-freebsd-x64: 1.32.0
1114 + lightningcss-linux-arm-gnueabihf: 1.32.0
1115 + lightningcss-linux-arm64-gnu: 1.32.0
1116 + lightningcss-linux-arm64-musl: 1.32.0
1117 + lightningcss-linux-x64-gnu: 1.32.0
1118 + lightningcss-linux-x64-musl: 1.32.0
1119 + lightningcss-win32-arm64-msvc: 1.32.0
1120 + lightningcss-win32-x64-msvc: 1.32.0
1121 +
1122 + lucide-react@1.44.0(react@19.2.8):
1123 + dependencies:
1124 + react: 19.2.8
1125 +
1126 + magic-string@0.30.21:
1127 + dependencies:
1128 + '@jridgewell/sourcemap-codec': 1.6.0
1129 +
1130 + nanoid@3.3.19: {}
1131 +
1132 + next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
1133 + dependencies:
1134 + '@next/env': 16.3.4
1135 + '@swc/helpers': 0.5.23
1136 + baseline-browser-mapping: 2.11.22
1137 + caniuse-lite: 1.0.30001810
1138 + postcss: 8.5.23
1139 + react: 19.2.8
1140 + react-dom: 19.2.8(react@19.2.8)
1141 + styled-jsx: 5.1.6(react@19.2.8)
1142 + optionalDependencies:
1143 + '@next/swc-darwin-arm64': 16.3.4
1144 + '@next/swc-darwin-x64': 16.3.4
1145 + '@next/swc-linux-arm64-gnu': 16.3.4
1146 + '@next/swc-linux-arm64-musl': 16.3.4
1147 + '@next/swc-linux-x64-gnu': 16.3.4
1148 + '@next/swc-linux-x64-musl': 16.3.4
1149 + '@next/swc-win32-arm64-msvc': 16.3.4
1150 + '@next/swc-win32-x64-msvc': 16.3.4
1151 + sharp: 0.35.4(@types/node@24.13.4)
1152 + transitivePeerDependencies:
1153 + - '@babel/core'
1154 + - '@types/node'
1155 + - babel-plugin-macros
1156 +
1157 + picocolors@1.1.1: {}
1158 +
1159 + postcss@8.5.23:
1160 + dependencies:
1161 + nanoid: 3.3.19
1162 + picocolors: 1.1.1
1163 + source-map-js: 1.2.1
1164 +
1165 + postcss@8.5.28:
1166 + dependencies:
1167 + nanoid: 3.3.19
1168 + picocolors: 1.1.1
1169 + source-map-js: 1.2.1
1170 +
1171 + react-dom@19.2.8(react@19.2.8):
1172 + dependencies:
1173 + react: 19.2.8
1174 + scheduler: 0.27.0
1175 +
1176 + react@19.2.8: {}
1177 +
1178 + scheduler@0.27.0: {}
1179 +
1180 + semver@7.8.5:
1181 + optional: true
1182 +
1183 + server-only@0.0.1: {}
1184 +
1185 + sharp@0.35.4(@types/node@24.13.4):
1186 + dependencies:
1187 + '@img/colour': 1.1.0
1188 + detect-libc: 2.1.2
1189 + semver: 7.8.5
1190 + optionalDependencies:
1191 + '@img/sharp-darwin-arm64': 0.35.4
1192 + '@img/sharp-darwin-x64': 0.35.4
1193 + '@img/sharp-freebsd-wasm32': 0.35.4
1194 + '@img/sharp-libvips-darwin-arm64': 1.3.3
1195 + '@img/sharp-libvips-darwin-x64': 1.3.3
1196 + '@img/sharp-libvips-linux-arm': 1.3.3
1197 + '@img/sharp-libvips-linux-arm64': 1.3.3
1198 + '@img/sharp-libvips-linux-ppc64': 1.3.3
1199 + '@img/sharp-libvips-linux-riscv64': 1.3.3
1200 + '@img/sharp-libvips-linux-s390x': 1.3.3
1201 + '@img/sharp-libvips-linux-x64': 1.3.3
1202 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
1203 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
1204 + '@img/sharp-linux-arm': 0.35.4
1205 + '@img/sharp-linux-arm64': 0.35.4
1206 + '@img/sharp-linux-ppc64': 0.35.4
1207 + '@img/sharp-linux-riscv64': 0.35.4
1208 + '@img/sharp-linux-s390x': 0.35.4
1209 + '@img/sharp-linux-x64': 0.35.4
1210 + '@img/sharp-linuxmusl-arm64': 0.35.4
1211 + '@img/sharp-linuxmusl-x64': 0.35.4
1212 + '@img/sharp-webcontainers-wasm32': 0.35.4
1213 + '@img/sharp-win32-arm64': 0.35.4
1214 + '@img/sharp-win32-ia32': 0.35.4
1215 + '@img/sharp-win32-x64': 0.35.4
1216 + '@types/node': 24.13.4
1217 + optional: true
1218 +
1219 + source-map-js@1.2.1: {}
1220 +
1221 + styled-jsx@5.1.6(react@19.2.8):
1222 + dependencies:
1223 + client-only: 0.0.1
1224 + react: 19.2.8
1225 +
1226 + tailwindcss@4.3.3: {}
1227 +
1228 + tapable@2.3.3: {}
1229 +
1230 + topojson-client@3.1.0:
1231 + dependencies:
1232 + commander: 2.20.3
1233 +
1234 + tslib@2.8.1: {}
1235 +
1236 + typescript@5.9.3: {}
1237 +
1238 + undici-types@7.18.2: {}
1239 +
1240 + world-atlas@2.0.2: {}
1241