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>
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
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/about/page.tsx
+68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/admin/[module]/page.tsx
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/admin/page.tsx
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/api/page.tsx
+147 −0
Large diff (147 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/apple-icon.tsx
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/bot/page.tsx
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/change/[id]/page.tsx
+100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/companies/page.tsx
+93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/company/[slug]/layout.tsx
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/company/[slug]/page.tsx
+212 −0
Large diff (212 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/company/compare/page.tsx
+153 −0
Large diff (153 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/country/[code]/page.tsx
+91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/country/page.tsx
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/error.tsx
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/events/[id]/page.tsx
+102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/events/page.tsx
+63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/globals.css
+739 −0
Large diff (739 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/icon-512/route.tsx
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/icon.svg
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/industry/[slug]/page.tsx
+111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/industry/page.tsx
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/layout.tsx
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/live/page.tsx
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/manifest.ts
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/methodology/page.tsx
+85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/not-found.tsx
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/opengraph-image.tsx
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/page.tsx
+254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/rankings/page.tsx
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/robots.ts
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/search/page.tsx
+177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sensor/[id]/page.tsx
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sitemap.xml/route.ts
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/sitemaps/sitemap.ts
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/snapshot/[id]/diff/[other]/page.tsx
+53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/snapshot/[id]/page.tsx
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/system/page.tsx
+71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/app/watchlist/page.tsx
+14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/admin/admin-shell.tsx
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/admin/modules.tsx
+727 −0
Large diff (727 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/brand/logo.tsx
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/brand/mark.tsx
+55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/bars.tsx
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/heatmap.tsx
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/line-chart.tsx
+156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/sparkline.tsx
+91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/world-map.tsx
+121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/company-header.tsx
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/company-table.tsx
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/compare-picker.tsx
+74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/metric-tiles.tsx
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/panels.tsx
+531 −0
Large diff (531 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/watch-button.tsx
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/company/watchlist-client.tsx
+235 −0
Large diff (235 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/events/diff-viewer.tsx
+85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/events/event-drawer-context.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/events/event-drawer.tsx
+74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/events/event-evidence.tsx
+133 −0
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/events/event-filters.tsx
+133 −0
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/events/event-row.tsx
+68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/density.tsx
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/mobile-tab-bar.tsx
+53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-context.tsx
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-dialog.tsx
+250 −0
Large diff (250 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-footer.tsx
+59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-header.tsx
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/theme.tsx
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/live/counters.tsx
+110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/live/live-feed.tsx
+253 −0
Large diff (253 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/rankings/rankings-table.tsx
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/badges.tsx
+163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/key-value.tsx
+14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/live.tsx
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/pagination.tsx
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/section.tsx
+115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/sheet.tsx
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/skeleton.tsx
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/ui/tabs.tsx
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/admin-modules.ts
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/admin.ts
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/api.ts
+205 −0
Large diff (205 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/client-api.ts
+118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/cn.ts
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/countries.ts
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/event-styles.ts
+154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/fonts.ts
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/format.ts
+203 −0
Large diff (203 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/owner.ts
+95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/params.ts
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/prepaint.ts
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/site.ts
+185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/types.ts
+751 −0
Large diff (751 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/tsconfig.json
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
docs/FRONTEND.md
+117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-lock.yaml
+1240 −0
Large diff (1,241 lines) is not loaded by default to keep the page fast.Load diff