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

web: rich company profile (logo, sourced description, key facts, corporate structure, merged executives, directory logos)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Sep 13, 2026) parent 797d5af

14 changed files +1,293 −107

modified apps/web/qa/mock-api.mjs +386 −3
@@ -223,6 +223,369 @@ const HEADLINES = ['expands enterprise offering to the EU', 'reports record quar
223 223 const PARTNERS = ['Microsoft', 'AWS', 'Google Cloud', 'Accenture', 'Deloitte', 'Salesforce', 'Visa', 'Mastercard', 'Snowflake', 'NVIDIA'];
224 224 const TECHS = ['Rust', 'Kubernetes', 'MCP', 'vector search', 'WebAssembly', 'PostgreSQL', 'Kafka', 'Terraform', 'Claude', 'LLM evaluation'];
225 225
226 +// ------------------------------------------------------------------------------------------------ enrichment profiles
227 +// Sample `CompanyCard.profile` payloads (mock only — figures are approximate public values used to exercise the UI, not
228 +// a dataset). Mix: complete (nvidia, apple, toyota, sony, roche, sap, spotify, adyen, arm, tsmc, siemens, samsung),
229 +// partial (stripe, cohere, anthropic, mistral-ai, shopify, nubank, revolut, klarna), null-filled (cabify, careem,
230 +// flutterwave, zerodha, discovery), absent key (compass, bitso, wiz), and an auto-derived minimal profile for the rest.
231 +const MOCK_ASSET_BASE = `http://127.0.0.1:${PORT}/api/v1/_mock`;
232 +const WD = (q) => `https://www.wikidata.org/wiki/${q}`;
233 +const WP = (t) => `https://en.wikipedia.org/wiki/${t}`;
234 +const money = (value, currency, year) => ({ value, currency, year });
235 +const RETRIEVED = iso(NOW - 3 * DAY);
236 +const NO_PROFILE_KEY = new Set(['compass', 'bitso', 'wiz']);
237 +const NULL_PROFILE = new Set(['cabify', 'careem', 'flutterwave', 'zerodha', 'discovery']);
238 +const PROFILES = {
239 + nvidia: {
240 + description: 'Nvidia Corporation is an American technology company headquartered in Santa Clara, California. It designs graphics processing units, system-on-a-chip units and application programming interfaces for data science, high-performance computing and mobile and automotive markets.',
241 + description_source: 'wikipedia', description_url: WP('Nvidia'), description_license: 'CC BY-SA 4.0',
242 + logo: true, icon: true, founded_year: 1993, legal_form: 'Public company (Delaware corporation)', employees: 36000, employees_year: 2025,
243 + revenue: money(130497000000, 'USD', 2025), net_income: money(72880000000, 'USD', 2025), total_assets: money(111601000000, 'USD', 2025),
244 + hq: { city: 'Santa Clara', region: 'California', country: 'US', address: '2788 San Tomas Expressway', lat: 37.3706, lon: -121.9636 },
245 + ticker: 'NVDA', exchange: 'NASDAQ', isin: 'US67066G1040', lei: '549300S4KLFTLO7GSQ80', sec_cik: '0001045810', public_company: true,
246 + wikipedia_url: WP('Nvidia'), wikidata_url: WD('Q182477'), official_website: 'https://www.nvidia.com', phone: '+1 408-486-2000',
247 + products: ['GeForce', 'Quadro', 'CUDA', 'DGX', 'Jetson', 'Tegra', 'NVIDIA DRIVE', 'Omniverse', 'Nvidia RTX'], industry_labels: ['semiconductor industry', 'artificial intelligence', 'computer hardware'],
248 + socials: { linkedin: 'https://www.linkedin.com/company/nvidia', x: 'https://x.com/nvidia', youtube: 'https://www.youtube.com/@NVIDIA', facebook: 'https://www.facebook.com/NVIDIA', instagram: 'https://www.instagram.com/nvidia', github: 'https://github.com/NVIDIA' },
249 + extra_facts: [{ key: 'index_membership', label: 'Index membership', value: 'S&P 500 · Nasdaq-100 · Dow Jones Industrial Average', source: 'wikidata' }],
250 + financial_source: 'sec_edgar',
251 + },
252 + apple: {
253 + description: 'Apple Inc. is an American multinational technology company headquartered in Cupertino, California. It designs, develops and sells consumer electronics, software and online services, including the iPhone, Mac, iPad, Apple Watch and the App Store.',
254 + description_source: 'wikipedia', description_url: WP('Apple_Inc.'), description_license: 'CC BY-SA 4.0',
255 + logo: true, icon: true, founded_year: 1976, legal_form: 'Public company (California corporation)', employees: 164000, employees_year: 2024,
256 + revenue: money(391035000000, 'USD', 2024), net_income: money(93736000000, 'USD', 2024), total_assets: money(364980000000, 'USD', 2024),
257 + hq: { city: 'Cupertino', region: 'California', country: 'US', address: 'One Apple Park Way', lat: 37.3349, lon: -122.009 },
258 + ticker: 'AAPL', exchange: 'NASDAQ', isin: 'US0378331005', lei: 'HWUPKR0MPOU8FGXBT394', sec_cik: '0000320193', public_company: true,
259 + wikipedia_url: WP('Apple_Inc.'), wikidata_url: WD('Q312'), official_website: 'https://www.apple.com', phone: '+1 408-996-1010',
260 + products: ['iPhone', 'iPad', 'Mac', 'Apple Watch', 'AirPods', 'Apple TV', 'Apple Vision Pro', 'iOS', 'macOS', 'App Store', 'Apple Music', 'iCloud'], industry_labels: ['consumer electronics', 'software industry', 'online services'],
261 + socials: { linkedin: 'https://www.linkedin.com/company/apple', x: 'https://x.com/Apple', youtube: 'https://www.youtube.com/@Apple', instagram: 'https://www.instagram.com/apple', facebook: 'https://www.facebook.com/apple' },
262 + extra_facts: [{ key: 'index_membership', label: 'Index membership', value: 'S&P 500 · Nasdaq-100 · Dow Jones Industrial Average', source: 'wikidata' }, { key: 'auditor', label: 'Auditor', value: 'Ernst & Young', source: 'sec_edgar' }],
263 + financial_source: 'sec_edgar',
264 + },
265 + toyota: {
266 + description: 'Toyota Motor Corporation is a Japanese multinational automotive manufacturer headquartered in Toyota City, Aichi. It is one of the largest automobile manufacturers in the world by production volume.',
267 + description_source: 'wikipedia', description_url: WP('Toyota'), description_license: 'CC BY-SA 4.0',
268 + logo: true, icon: true, founded_year: 1937, legal_form: 'Kabushiki gaisha (public)', employees: 380793, employees_year: 2024,
269 + revenue: money(48036704000000, 'JPY', 2025), net_income: money(4765086000000, 'JPY', 2025), total_assets: money(93601350000000, 'JPY', 2025),
270 + hq: { city: 'Toyota City', region: 'Aichi', country: 'JP', address: '1 Toyota-cho', lat: 35.0826, lon: 137.1562 },
271 + ticker: '7203', exchange: 'TSE', isin: 'JP3633400001', lei: '5493006W3QUS5LMH6R84', sec_cik: '0001094517', public_company: true,
272 + wikipedia_url: WP('Toyota'), wikidata_url: WD('Q53268'), official_website: 'https://global.toyota', phone: null,
273 + products: ['Toyota Corolla', 'Toyota Camry', 'Toyota RAV4', 'Toyota Prius', 'Toyota Hilux', 'Toyota Land Cruiser', 'Lexus'], industry_labels: ['automotive industry'],
274 + socials: { linkedin: 'https://www.linkedin.com/company/toyota', x: 'https://x.com/Toyota', youtube: 'https://www.youtube.com/@toyotaglobal' },
275 + },
276 + sony: {
277 + description: 'Sony Group Corporation is a Japanese multinational conglomerate headquartered in Minato, Tokyo, active in electronics, gaming, entertainment (pictures and music), imaging sensors and financial services.',
278 + description_source: 'homepage', description_url: 'https://www.sony.com/en/SonyInfo/CorporateInfo/', description_license: null,
279 + logo: true, icon: false, founded_year: 1946, legal_form: 'Kabushiki gaisha (public)', employees: 113000, employees_year: 2024,
280 + revenue: money(12957000000000, 'JPY', 2025), net_income: money(1141600000000, 'JPY', 2025), total_assets: money(35300000000000, 'JPY', 2025),
281 + hq: { city: 'Minato', region: 'Tokyo', country: 'JP', address: '1-7-1 Konan', lat: 35.6299, lon: 139.7402 },
282 + ticker: '6758', exchange: 'TSE', isin: 'JP3435000009', lei: '353800A2DP3ZMC4LR436', sec_cik: '0000313838', public_company: true,
283 + wikipedia_url: WP('Sony'), wikidata_url: WD('Q41187'), official_website: 'https://www.sony.com', phone: null,
284 + products: ['PlayStation 5', 'Sony Alpha', 'Bravia', 'WH-1000XM5', 'Xperia', 'Sony Pictures', 'Sony Music'], industry_labels: ['conglomerate', 'consumer electronics', 'entertainment industry'],
285 + socials: { linkedin: 'https://www.linkedin.com/company/sony', x: 'https://x.com/Sony', youtube: 'https://www.youtube.com/@Sony', instagram: 'https://www.instagram.com/sony', facebook: 'https://www.facebook.com/Sony', tiktok: 'https://www.tiktok.com/@sony' },
286 + },
287 + roche: {
288 + description: 'F. Hoffmann-La Roche AG is a Swiss multinational healthcare company operating worldwide under two divisions: Pharmaceuticals and Diagnostics. It is headquartered in Basel.',
289 + description_source: 'wikipedia', description_url: WP('Hoffmann-La_Roche'), description_license: 'CC BY-SA 4.0',
290 + logo: true, icon: true, founded_year: 1896, legal_form: 'Aktiengesellschaft (public)', employees: 103613, employees_year: 2024,
291 + revenue: money(60500000000, 'CHF', 2024), net_income: money(9186000000, 'CHF', 2024), total_assets: money(93700000000, 'CHF', 2024),
292 + hq: { city: 'Basel', region: 'Basel-Stadt', country: 'CH', address: 'Grenzacherstrasse 124', lat: 47.5615, lon: 7.6086 },
293 + ticker: 'ROG', exchange: 'SIX', isin: 'CH0012032048', lei: '549300U41AUUVOAZRV96', sec_cik: null, public_company: true,
294 + wikipedia_url: WP('Hoffmann-La_Roche'), wikidata_url: WD('Q212646'), official_website: 'https://www.roche.com', phone: '+41 61 688 11 11',
295 + products: ['Ocrevus', 'Hemlibra', 'Tecentriq', 'Vabysmo', 'Perjeta', 'cobas', 'Accu-Chek'], industry_labels: ['pharmaceutical industry', 'in vitro diagnostics'],
296 + socials: { linkedin: 'https://www.linkedin.com/company/roche', x: 'https://x.com/Roche', youtube: 'https://www.youtube.com/@roche' },
297 + },
298 + sap: {
299 + description: 'SAP SE is a German multinational software company based in Walldorf, Baden-Württemberg, that develops enterprise software to manage business operations and customer relations.',
300 + description_source: 'wikipedia', description_url: WP('SAP'), description_license: 'CC BY-SA 4.0',
301 + logo: true, icon: true, founded_year: 1972, legal_form: 'Societas Europaea (SE)', employees: 109121, employees_year: 2024,
302 + revenue: money(34176000000, 'EUR', 2024), net_income: money(3096000000, 'EUR', 2024), total_assets: money(69700000000, 'EUR', 2024),
303 + hq: { city: 'Walldorf', region: 'Baden-Württemberg', country: 'DE', address: 'Dietmar-Hopp-Allee 16', lat: 49.2933, lon: 8.6414 },
304 + ticker: 'SAP', exchange: 'XETRA', isin: 'DE0007164600', lei: '529900D6BF99LW9R2E68', sec_cik: '0001000184', public_company: true,
305 + wikipedia_url: WP('SAP'), wikidata_url: WD('Q166262'), official_website: 'https://www.sap.com', phone: '+49 6227 7-47474',
306 + products: ['SAP S/4HANA', 'SAP HANA', 'SAP Business Technology Platform', 'SAP SuccessFactors', 'SAP Ariba', 'SAP Concur', 'Joule'], industry_labels: ['software industry', 'enterprise software'],
307 + socials: { linkedin: 'https://www.linkedin.com/company/sap', x: 'https://x.com/SAP', youtube: 'https://www.youtube.com/@SAP', github: 'https://github.com/SAP' },
308 + },
309 + spotify: {
310 + description: 'Spotify Technology S.A. is a Swedish audio streaming and media services provider founded in 2006 and headquartered in Stockholm, with its legal seat in Luxembourg.',
311 + description_source: 'wikipedia', description_url: WP('Spotify'), description_license: 'CC BY-SA 4.0',
312 + logo: true, icon: true, founded_year: 2006, legal_form: 'Société anonyme (Luxembourg)', employees: 7359, employees_year: 2024,
313 + revenue: money(15673000000, 'EUR', 2024), net_income: money(1138000000, 'EUR', 2024), total_assets: money(11100000000, 'EUR', 2024),
314 + hq: { city: 'Stockholm', region: null, country: 'SE', address: 'Regeringsgatan 19', lat: 59.3326, lon: 18.0649 },
315 + ticker: 'SPOT', exchange: 'NYSE', isin: 'LU1778762911', lei: '549300I8UDPDOMRCNP86', sec_cik: '0001639920', public_company: true,
316 + wikipedia_url: WP('Spotify'), wikidata_url: WD('Q689141'), official_website: 'https://www.spotify.com', phone: null,
317 + products: ['Spotify', 'Spotify Premium', 'Spotify for Artists', 'Spotify for Podcasters', 'Anchor'], industry_labels: ['music streaming', 'podcasting'],
318 + socials: { linkedin: 'https://www.linkedin.com/company/spotify', x: 'https://x.com/Spotify', youtube: 'https://www.youtube.com/@Spotify', instagram: 'https://www.instagram.com/spotify', github: 'https://github.com/spotify' },
319 + },
320 + adyen: {
321 + description: 'Adyen N.V. is a Dutch payment company headquartered in Amsterdam that provides a single platform for accepting payments across online, mobile and in-store channels.',
322 + description_source: 'wikipedia', description_url: WP('Adyen'), description_license: 'CC BY-SA 4.0',
323 + logo: true, icon: true, founded_year: 2006, legal_form: 'Naamloze vennootschap (public)', employees: 4322, employees_year: 2024,
324 + revenue: money(1996000000, 'EUR', 2024), net_income: money(925000000, 'EUR', 2024), total_assets: null,
325 + hq: { city: 'Amsterdam', region: 'North Holland', country: 'NL', address: 'Simon Carmiggeltstraat 6-50', lat: 52.3765, lon: 4.9016 },
326 + ticker: 'ADYEN', exchange: 'Euronext Amsterdam', isin: 'NL0012969182', lei: '724500PSWKAY73WSLD26', sec_cik: null, public_company: true,
327 + wikipedia_url: WP('Adyen'), wikidata_url: WD('Q19833716'), official_website: 'https://www.adyen.com', phone: null,
328 + products: ['Adyen Platform', 'Adyen for Platforms', 'Adyen Issuing', 'Adyen Terminal'], industry_labels: ['payment service provider', 'financial technology'],
329 + socials: { linkedin: 'https://www.linkedin.com/company/adyen', x: 'https://x.com/Adyen', youtube: 'https://www.youtube.com/@adyen', github: 'https://github.com/Adyen' },
330 + },
331 + arm: {
332 + description: 'Arm Holdings plc is a British semiconductor and software design company based in Cambridge that licenses processor architectures and IP cores; majority-owned by SoftBank Group.',
333 + description_source: 'wikipedia', description_url: WP('Arm_Holdings'), description_license: 'CC BY-SA 4.0',
334 + logo: true, icon: true, founded_year: 1990, legal_form: 'Public limited company', employees: 8300, employees_year: 2025,
335 + revenue: money(4007000000, 'USD', 2025), net_income: money(792000000, 'USD', 2025), total_assets: money(8700000000, 'USD', 2025),
336 + hq: { city: 'Cambridge', region: 'Cambridgeshire', country: 'GB', address: '110 Fulbourn Road', lat: 52.1839, lon: 0.1791 },
337 + ticker: 'ARM', exchange: 'NASDAQ', isin: 'US0420682058', lei: '213800ND9OV4ZZKK7O47', sec_cik: '0001973239', public_company: true,
338 + wikipedia_url: WP('Arm_Holdings'), wikidata_url: WD('Q1063165'), official_website: 'https://www.arm.com', phone: null,
339 + products: ['Cortex-A', 'Cortex-M', 'Neoverse', 'Mali', 'Armv9', 'Arm Compute Subsystems'], industry_labels: ['semiconductor industry', 'intellectual property licensing'],
340 + socials: { linkedin: 'https://www.linkedin.com/company/arm', x: 'https://x.com/Arm', youtube: 'https://www.youtube.com/@Arm', github: 'https://github.com/ARM-software' },
341 + financial_source: 'sec_edgar',
342 + },
343 + tsmc: {
344 + description: 'Taiwan Semiconductor Manufacturing Company Limited is a Taiwanese multinational semiconductor contract manufacturing and design company headquartered in Hsinchu Science Park; it is the world’s largest dedicated independent semiconductor foundry.',
345 + description_source: 'wikipedia', description_url: WP('TSMC'), description_license: 'CC BY-SA 4.0',
346 + logo: true, icon: true, founded_year: 1987, legal_form: 'Public company', employees: 83825, employees_year: 2024,
347 + revenue: money(2894307000000, 'TWD', 2024), net_income: money(1173268000000, 'TWD', 2024), total_assets: money(6691000000000, 'TWD', 2024),
348 + hq: { city: 'Hsinchu', region: null, country: 'TW', address: '8 Li-Hsin Road 6, Hsinchu Science Park', lat: 24.7739, lon: 121.0107 },
349 + ticker: '2330', exchange: 'TWSE', isin: 'TW0002330008', lei: '549300YSKHMGWXOR9E51', sec_cik: '0001046179', public_company: true,
350 + wikipedia_url: WP('TSMC'), wikidata_url: WD('Q713418'), official_website: 'https://www.tsmc.com', phone: null,
351 + products: ['3 nm process', '5 nm process', 'CoWoS', 'InFO'], industry_labels: ['semiconductor industry', 'semiconductor fabrication'],
352 + socials: { linkedin: 'https://www.linkedin.com/company/tsmc', youtube: 'https://www.youtube.com/@tsmc' },
353 + },
354 + siemens: {
355 + description: 'Siemens AG is a German multinational technology conglomerate headquartered in Munich, focused on industrial automation, smart infrastructure, rail transport and, through Siemens Healthineers, medical technology.',
356 + description_source: 'wikipedia', description_url: WP('Siemens'), description_license: 'CC BY-SA 4.0',
357 + logo: true, icon: true, founded_year: 1847, legal_form: 'Aktiengesellschaft (public)', employees: 327000, employees_year: 2024,
358 + revenue: money(75930000000, 'EUR', 2024), net_income: money(9000000000, 'EUR', 2024), total_assets: money(146000000000, 'EUR', 2024),
359 + hq: { city: 'Munich', region: 'Bavaria', country: 'DE', address: 'Werner-von-Siemens-Straße 1', lat: 48.1396, lon: 11.5744 },
360 + ticker: 'SIE', exchange: 'XETRA', isin: 'DE0007236101', lei: 'W38RGI023J3WT1HWRP32', sec_cik: null, public_company: true,
361 + wikipedia_url: WP('Siemens'), wikidata_url: WD('Q81230'), official_website: 'https://www.siemens.com', phone: '+49 89 636-00',
362 + products: ['SIMATIC', 'Siemens Xcelerator', 'TIA Portal', 'Desigo', 'Velaro', 'Mobility Vectron'], industry_labels: ['industrial automation', 'electrical engineering', 'rail transport'],
363 + socials: { linkedin: 'https://www.linkedin.com/company/siemens', x: 'https://x.com/Siemens', youtube: 'https://www.youtube.com/@Siemens', instagram: 'https://www.instagram.com/siemens' },
364 + },
365 + 'samsung-electronics': {
366 + description: 'Samsung Electronics Co., Ltd. is a South Korean multinational electronics company headquartered in Suwon, and the flagship affiliate of the Samsung Group, producing memory chips, displays, smartphones and home appliances.',
367 + description_source: 'wikipedia', description_url: WP('Samsung_Electronics'), description_license: 'CC BY-SA 4.0',
368 + logo: true, icon: true, founded_year: 1969, legal_form: 'Chusik hoesa (public)', employees: 267860, employees_year: 2023,
369 + revenue: money(300870900000000, 'KRW', 2024), net_income: money(34451000000000, 'KRW', 2024), total_assets: money(514531900000000, 'KRW', 2024),
370 + hq: { city: 'Suwon', region: 'Gyeonggi', country: 'KR', address: '129 Samsung-ro, Yeongtong-gu', lat: 37.2599, lon: 127.0303 },
371 + ticker: '005930', exchange: 'KRX', isin: 'KR7005930003', lei: '988400E5HRVX81AYLM04', sec_cik: null, public_company: true,
372 + wikipedia_url: WP('Samsung_Electronics'), wikidata_url: WD('Q20718'), official_website: 'https://www.samsung.com', phone: null,
373 + products: ['Galaxy S', 'Galaxy Z', 'Galaxy Tab', 'Neo QLED', 'Bespoke', 'Exynos', 'HBM3E'], industry_labels: ['consumer electronics', 'semiconductor industry', 'display technology'],
374 + socials: { linkedin: 'https://www.linkedin.com/company/samsung-electronics', x: 'https://x.com/Samsung', youtube: 'https://www.youtube.com/@Samsung', instagram: 'https://www.instagram.com/samsung', facebook: 'https://www.facebook.com/SamsungGlobal', tiktok: 'https://www.tiktok.com/@samsung' },
375 + },
376 + // ---- partial profiles
377 + stripe: {
378 + description: 'Stripe, Inc. is an Irish-American multinational financial services and software-as-a-service company dual-headquartered in South San Francisco, California, and Dublin, Ireland. It offers payment-processing software and APIs for e-commerce websites and mobile applications.',
379 + description_source: 'wikipedia', description_url: WP('Stripe,_Inc.'), description_license: 'CC BY-SA 4.0',
380 + logo: true, icon: true, founded_year: 2010, legal_form: 'Private company', employees: 8550, employees_year: 2024,
381 + hq: { city: 'South San Francisco', region: 'California', country: 'US', address: '354 Oyster Point Blvd', lat: 37.6547, lon: -122.3894 },
382 + public_company: false, wikipedia_url: WP('Stripe,_Inc.'), wikidata_url: WD('Q10318979'), official_website: 'https://stripe.com',
383 + products: ['Stripe Payments', 'Stripe Connect', 'Stripe Billing', 'Stripe Terminal', 'Stripe Radar', 'Stripe Atlas', 'Stripe Issuing', 'Stripe Treasury'], industry_labels: ['payment service provider', 'financial technology'],
384 + socials: { linkedin: 'https://www.linkedin.com/company/stripe', x: 'https://x.com/stripe', youtube: 'https://www.youtube.com/@StripeDevelopers', github: 'https://github.com/stripe', crunchbase: 'https://www.crunchbase.com/organization/stripe' },
385 + },
386 + shopify: {
387 + description: 'Shopify Inc. is a Canadian multinational e-commerce company headquartered in Ottawa, Ontario, that provides a proprietary platform for online stores and retail point-of-sale systems.',
388 + description_source: 'wikipedia', description_url: WP('Shopify'), description_license: 'CC BY-SA 4.0',
389 + logo: true, icon: true, founded_year: 2006, legal_form: 'Public company (Canada Business Corporations Act)', employees: 8100, employees_year: 2024,
390 + revenue: money(8880000000, 'USD', 2024), net_income: money(2020000000, 'USD', 2024),
391 + hq: { city: 'Ottawa', region: 'Ontario', country: 'CA', address: '151 O’Connor Street', lat: 45.4215, lon: -75.6972 },
392 + ticker: 'SHOP', exchange: 'TSX · NASDAQ', isin: 'CA82509L1076', lei: '549300HPKKP5AVXRM893', sec_cik: '0001594805', public_company: true,
393 + wikipedia_url: WP('Shopify'), wikidata_url: WD('Q3963870'), official_website: 'https://www.shopify.com',
394 + products: ['Shopify', 'Shopify Plus', 'Shopify POS', 'Shopify Payments', 'Shop Pay', 'Shop app', 'Shopify Magic'], industry_labels: ['e-commerce', 'software as a service'],
395 + socials: { linkedin: 'https://www.linkedin.com/company/shopify', x: 'https://x.com/Shopify', youtube: 'https://www.youtube.com/@Shopify', github: 'https://github.com/Shopify' },
396 + financial_source: 'sec_edgar',
397 + },
398 + cohere: {
399 + description: 'Cohere is a Toronto-based AI company that builds large language models and retrieval systems for enterprises, offered through an API and deployed in private clouds. Its public pages emphasise data privacy and multilingual models.',
400 + description_source: 'llm', description_url: null, description_license: null,
401 + logo: false, icon: true, founded_year: 2019, legal_form: 'Private company', employees: null, employees_year: null,
402 + hq: { city: 'Toronto', region: 'Ontario', country: 'CA', address: null, lat: 43.6532, lon: -79.3832 },
403 + public_company: false, wikipedia_url: WP('Cohere'), wikidata_url: WD('Q108024780'), official_website: 'https://cohere.com',
404 + products: ['Command', 'Embed', 'Rerank', 'Aya', 'North'], industry_labels: ['artificial intelligence'],
405 + socials: { linkedin: 'https://www.linkedin.com/company/cohere-ai', x: 'https://x.com/cohere', github: 'https://github.com/cohere-ai', youtube: 'https://www.youtube.com/@cohere-ai' },
406 + },
407 + anthropic: {
408 + description: 'American artificial intelligence company founded in 2021, developer of the Claude family of large language models.',
409 + description_source: 'wikidata', description_url: WD('Q109832790'), description_license: 'CC0',
410 + logo: false, icon: true, founded_year: 2021, legal_form: 'Public-benefit corporation', employees: null, employees_year: null,
411 + hq: { city: 'San Francisco', region: 'California', country: 'US', address: null, lat: 37.7749, lon: -122.4194 },
412 + public_company: false, wikipedia_url: WP('Anthropic'), wikidata_url: WD('Q109832790'), official_website: 'https://www.anthropic.com',
413 + products: ['Claude'], industry_labels: ['artificial intelligence'],
414 + socials: { linkedin: 'https://www.linkedin.com/company/anthropicresearch', x: 'https://x.com/AnthropicAI', youtube: 'https://www.youtube.com/@anthropic-ai', github: 'https://github.com/anthropics' },
415 + },
416 + 'mistral-ai': {
417 + description: 'Mistral AI is a French artificial intelligence company headquartered in Paris that develops open-weight and commercial large language models.',
418 + description_source: 'wikipedia', description_url: WP('Mistral_AI'), description_license: 'CC BY-SA 4.0',
419 + logo: true, icon: false, founded_year: 2023, legal_form: 'Société par actions simplifiée', employees: null, employees_year: null,
420 + hq: { city: 'Paris', region: 'Île-de-France', country: 'FR', address: null, lat: 48.8566, lon: 2.3522 },
421 + public_company: false, wikipedia_url: WP('Mistral_AI'), wikidata_url: WD('Q119711183'), official_website: 'https://mistral.ai',
422 + products: ['Mistral Large', 'Mistral Small', 'Codestral', 'Le Chat', 'Pixtral'], industry_labels: ['artificial intelligence'],
423 + socials: { linkedin: 'https://www.linkedin.com/company/mistralai', x: 'https://x.com/MistralAI', github: 'https://github.com/mistralai' },
424 + },
425 + nubank: {
426 + description: 'Nu Holdings Ltd. is a Brazilian neobank headquartered in São Paulo, operating in Brazil, Mexico and Colombia; it is one of the largest digital banking platforms in the world by customer count.',
427 + description_source: 'wikipedia', description_url: WP('Nubank'), description_license: 'CC BY-SA 4.0',
428 + logo: true, icon: true, founded_year: 2013, legal_form: 'Cayman Islands holding company', employees: null, employees_year: null,
429 + revenue: money(11500000000, 'USD', 2024), net_income: money(1970000000, 'USD', 2024),
430 + hq: { city: 'São Paulo', region: 'São Paulo', country: 'BR', address: null, lat: -23.5505, lon: -46.6333 },
431 + ticker: 'NU', exchange: 'NYSE', isin: 'KYG6683N1034', sec_cik: '0001691493', public_company: true,
432 + wikipedia_url: WP('Nubank'), wikidata_url: WD('Q28129905'), official_website: 'https://nubank.com.br',
433 + products: ['Nu conta', 'Nu cartão', 'NuInvest', 'Nu Pagamentos'], industry_labels: ['neobank', 'financial technology'],
434 + socials: { linkedin: 'https://www.linkedin.com/company/nubank', x: 'https://x.com/nubank', instagram: 'https://www.instagram.com/nubank', youtube: 'https://www.youtube.com/@nubank' },
435 + financial_source: 'sec_edgar',
436 + },
437 + revolut: {
438 + description: 'Revolut Group Holdings Ltd is a British fintech company headquartered in London offering banking services, including multi-currency accounts, cards, trading and crypto, to retail and business customers.',
439 + description_source: 'wikipedia', description_url: WP('Revolut'), description_license: 'CC BY-SA 4.0',
440 + logo: true, icon: true, founded_year: 2015, legal_form: 'Private limited company', employees: 10000, employees_year: 2024,
441 + hq: { city: 'London', region: 'England', country: 'GB', address: null, lat: 51.5074, lon: -0.1278 },
442 + public_company: false, wikipedia_url: WP('Revolut'), wikidata_url: WD('Q21179207'), official_website: 'https://www.revolut.com',
443 + products: ['Revolut', 'Revolut Business', 'Revolut X', 'Revolut <18'], industry_labels: ['neobank', 'financial technology'],
444 + socials: { linkedin: 'https://www.linkedin.com/company/revolut', x: 'https://x.com/RevolutApp', instagram: 'https://www.instagram.com/revolutapp', youtube: 'https://www.youtube.com/@Revolut' },
445 + },
446 + klarna: {
447 + description: 'Klarna Group plc is a Swedish fintech company that provides online financial services such as payments for online storefronts, direct payments and post-purchase payments.',
448 + description_source: 'wikipedia', description_url: WP('Klarna'), description_license: 'CC BY-SA 4.0',
449 + logo: true, icon: false, founded_year: 2005, legal_form: 'Public limited company', employees: 3422, employees_year: 2024,
450 + revenue: money(2810000000, 'USD', 2024),
451 + hq: { city: 'Stockholm', region: null, country: 'SE', address: 'Sveavägen 46', lat: 59.3376, lon: 18.0603 },
452 + ticker: 'KLAR', exchange: 'NYSE', public_company: true, wikipedia_url: WP('Klarna'), wikidata_url: WD('Q1747210'), official_website: 'https://www.klarna.com',
453 + products: ['Klarna', 'Pay in 4', 'Klarna Card'], industry_labels: ['financial technology', 'buy now, pay later'],
454 + socials: { linkedin: 'https://www.linkedin.com/company/klarna', x: 'https://x.com/Klarna', instagram: 'https://www.instagram.com/klarna' },
455 + },
456 +};
457 +
458 +/** Build a full-shape profile from a sample; `null` fills everything the sample omits (the UI must hide those). */
459 +function makeProfile(c, sample) {
460 + const base = {
461 + description: null, description_source: null, description_url: null, description_license: null,
462 + logo_url: null, icon_url: null, founded_year: null, legal_form: null, employees: null, employees_year: null, revenue: null, net_income: null, total_assets: null,
463 + hq: { city: null, region: null, country: null, address: null, lat: null, lon: null },
464 + ticker: null, exchange: null, isin: null, lei: null, sec_cik: null, public_company: false,
465 + wikipedia_url: null, wikidata_url: null, official_website: null, phone: null, products: [], industries: [], industry_labels: [], socials: {}, enriched_at: null, sources: [],
466 + };
467 + if (!sample) return base;
468 + const { logo, icon, extra_facts, financial_source, ...rest } = sample;
469 + const p = { ...base, ...rest, hq: { ...base.hq, ...(rest.hq ?? {}) } };
470 + if (logo) p.logo_url = `${MOCK_ASSET_BASE}/logo/${c.slug}.svg`;
471 + if (icon) p.icon_url = `${MOCK_ASSET_BASE}/icon/${c.slug}.svg`;
472 + p.enriched_at = RETRIEVED;
473 + const src = (field, source, url) => p.sources.push({ field, source, url: url ?? null, retrieved_at: RETRIEVED });
474 + if (p.description) src('description', p.description_source ?? 'wikidata', p.description_url);
475 + if (p.logo_url || p.icon_url) src('logo_url', 'wikidata', p.wikidata_url);
476 + if (p.founded_year) src('founded_year', 'wikidata', p.wikidata_url);
477 + if (p.legal_form) src('legal_form', 'wikidata', p.wikidata_url);
478 + if (p.hq.city || p.hq.country) src('hq', 'wikidata', p.wikidata_url);
479 + if (typeof p.employees === 'number') src('employees', financial_source === 'sec_edgar' ? 'sec_edgar' : 'wikidata', financial_source === 'sec_edgar' && p.sec_cik ? `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${p.sec_cik}` : p.wikidata_url);
480 + for (const k of ['revenue', 'net_income', 'total_assets']) if (p[k]) src(k, financial_source === 'sec_edgar' ? 'sec_edgar' : 'wikidata', financial_source === 'sec_edgar' && p.sec_cik ? `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${p.sec_cik}` : p.wikidata_url);
481 + if (p.ticker) src('ticker', 'wikidata', p.wikidata_url);
482 + if (p.isin) src('isin', 'wikidata', p.wikidata_url);
483 + if (p.lei) src('lei', 'gleif', `https://search.gleif.org/#/record/${p.lei}`);
484 + if (p.sec_cik) src('sec_cik', 'sec_edgar', `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${p.sec_cik}`);
485 + if (p.products.length) src('products', 'wikidata', p.wikidata_url);
486 + if (p.industry_labels.length) src('industry_labels', 'wikidata', p.wikidata_url);
487 + if (p.phone) src('phone', 'homepage', p.official_website);
488 + if (Object.keys(p.socials).length) src('socials', 'homepage', p.official_website);
489 + if (p.wikipedia_url) src('wikipedia_url', 'wikidata', p.wikidata_url);
490 + return p;
491 +}
492 +/** Minimal auto profile for companies without a hand-written sample: registry description, HQ, listing; no logo, no numbers. */
493 +function autoProfile(c) {
494 + return makeProfile(c, { description: c.description, description_source: 'homepage', description_url: c.website, founded_year: c.founded_year, hq: { city: c.hq_city, country: c.country, lat: c._lat, lon: c._lon }, ticker: c.ticker, exchange: c.exchange, public_company: c.public_company, official_website: c.website, logo: false, icon: chance(0.6) });
495 +}
496 +/** Flattened, pre-formatted facts (`CompanyDetail.facts`) — the structured profile + registry extras. */
497 +function factsOf(c) {
498 + const p = c.profile;
499 + if (!p) return [];
500 + const out = [];
501 + const bySrc = Object.fromEntries(p.sources.map((s) => [s.field, s]));
502 + const add = (key, label, value, field = key) => {
503 + const s = bySrc[field];
504 + if (value === null || value === undefined || value === '') return;
505 + out.push({ key, label, value: String(value), source: s?.source ?? 'wikidata', url: s?.url ?? p.wikidata_url, retrieved_at: s?.retrieved_at ?? RETRIEVED });
506 + };
507 + add('founded_year', 'Founded', p.founded_year);
508 + add('hq', 'Headquarters', [p.hq.city, p.hq.region, p.hq.country].filter(Boolean).join(', ') || null);
509 + add('employees', 'Employees', typeof p.employees === 'number' ? `${p.employees}${p.employees_year ? ` (${p.employees_year})` : ''}` : null);
510 + for (const [k, label] of [['revenue', 'Revenue'], ['net_income', 'Net income'], ['total_assets', 'Total assets']]) if (p[k]) add(k, label, `${p[k].value} ${p[k].currency} (${p[k].year})`);
511 + add('legal_form', 'Legal form', p.legal_form);
512 + add('isin', 'ISIN', p.isin);
513 + add('lei', 'LEI', p.lei);
514 + add('sec_cik', 'SEC CIK', p.sec_cik);
515 + for (const f of PROFILES[c.slug]?.extra_facts ?? []) out.push({ key: f.key, label: f.label, value: f.value, source: f.source, url: f.source === 'wikidata' ? p.wikidata_url : null, retrieved_at: RETRIEVED });
516 + return out;
517 +}
518 +
519 +// relationships in the v1.1 shape: [kind, counterpart (atlas slug or plain name), valid_from, valid_to, confidence, source, property]
520 +const WDP = (prop) => ({ source: 'wikidata', property: prop });
521 +const RELATIONS = {
522 + nvidia: [['PARENT_OF', 'Mellanox Technologies', '2020-04-27', null, 0.97, WDP('P355')], ['PARENT_OF', 'Cumulus Networks', '2020-05-04', null, 0.9, WDP('P355')], ['PARENT_OF', 'NVIDIA GmbH', null, null, 0.85, WDP('P355')], ['ACQUIRED', 'Run:ai', '2024-12-30', null, 0.9, WDP('P1830')], ['ACQUIRED', 'OctoAI', '2024-09-30', null, 0.8, WDP('P1830')], ['PARTNER', 'tsmc', null, null, 0.75, { source: 'registry' }], ['COMPETITOR', 'arm', null, null, 0.6, { source: 'registry' }]],
523 + apple: [['PARENT_OF', 'Beats Electronics', '2014-08-01', null, 0.98, WDP('P355')], ['PARENT_OF', 'Shazam', '2018-09-24', null, 0.95, WDP('P355')], ['PARENT_OF', 'Claris International', '1998-01-01', null, 0.9, WDP('P355')], ['PARENT_OF', 'Apple Sales International', null, null, 0.85, WDP('P355')], ['PARENT_OF', 'Braeburn Capital', '2005-01-01', null, 0.85, WDP('P355')], ['ACQUIRED', 'NeXT', '1997-02-07', null, 0.99, WDP('P1830')], ['PARTNER', 'tsmc', null, null, 0.7, { source: 'registry' }], ['COMPETITOR', 'samsung-electronics', null, null, 0.7, { source: 'registry' }]],
524 + sony: [['PARENT_OF', 'Sony Interactive Entertainment', '2016-04-01', null, 0.98, WDP('P355')], ['PARENT_OF', 'Sony Pictures Entertainment', '1991-08-07', null, 0.98, WDP('P355')], ['PARENT_OF', 'Sony Music Entertainment', '1991-01-01', null, 0.98, WDP('P355')], ['PARENT_OF', 'Sony Semiconductor Solutions', '2016-04-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'Sony Music Publishing', '2012-06-29', null, 0.9, WDP('P355')], ['PARENT_OF', 'Crunchyroll', '2021-08-09', null, 0.95, WDP('P355')], ['PARENT_OF', 'Bungie', '2022-07-15', null, 0.95, WDP('P355')], ['PARENT_OF', 'Insomniac Games', '2019-08-19', null, 0.95, WDP('P355')], ['PARENT_OF', 'Aniplex', '1995-09-01', null, 0.9, WDP('P355')], ['PARENT_OF', 'Sony Honda Mobility', '2022-09-28', null, 0.8, WDP('P355')], ['PARENT_OF', 'Sony Financial Group', '2004-04-01', '2025-10-01', 0.85, WDP('P355')], ['OWNER_OF', 'Olympus Corporation', '2012-09-28', '2019-08-30', 0.8, WDP('P1830')], ['COMPETITOR', 'samsung-electronics', null, null, 0.6, { source: 'registry' }]],
525 + toyota: [['PARENT_OF', 'Daihatsu', '2016-08-01', null, 0.98, WDP('P355')], ['PARENT_OF', 'Hino Motors', '2001-01-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'Toyota Financial Services', '2000-07-01', null, 0.9, WDP('P355')], ['PARENT_OF', 'Woven by Toyota', '2021-01-01', null, 0.85, WDP('P355')], ['PARENT_OF', 'Toyota Motor Europe', null, null, 0.85, WDP('P355')], ['OWNER_OF', 'Subaru Corporation', '2019-12-27', null, 0.8, WDP('P1830')], ['OWNER_OF', 'Mazda Motor Corporation', '2017-08-04', null, 0.7, WDP('P1830')]],
526 + roche: [['PARENT_OF', 'Genentech', '2009-03-26', null, 0.99, WDP('P355')], ['PARENT_OF', 'Chugai Pharmaceutical', '2002-10-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'Foundation Medicine', '2018-07-31', null, 0.95, WDP('P355')], ['PARENT_OF', 'Spark Therapeutics', '2019-12-17', null, 0.95, WDP('P355')], ['PARENT_OF', 'Flatiron Health', '2018-04-06', null, 0.9, WDP('P355')], ['PARENT_OF', 'Ventana Medical Systems', '2008-02-01', null, 0.9, WDP('P355')]],
527 + sap: [['PARENT_OF', 'Qualtrics', '2019-01-23', '2023-06-28', 0.95, WDP('P355')], ['PARENT_OF', 'SAP Concur', '2014-12-04', null, 0.95, WDP('P355')], ['PARENT_OF', 'SAP Ariba', '2012-10-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'SAP SuccessFactors', '2012-02-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'SAP Fieldglass', '2014-05-01', null, 0.9, WDP('P355')], ['PARENT_OF', 'Signavio', '2021-03-05', null, 0.9, WDP('P355')], ['PARENT_OF', 'LeanIX', '2023-11-08', null, 0.85, WDP('P355')], ['PARENT_OF', 'WalkMe', '2024-09-12', null, 0.85, WDP('P355')], ['PARTNER', 'siemens', null, null, 0.5, { source: 'registry' }]],
528 + spotify: [['PARENT_OF', 'Anchor', '2019-02-06', null, 0.9, WDP('P355')], ['PARENT_OF', 'Megaphone', '2020-12-15', null, 0.9, WDP('P355')], ['PARENT_OF', 'Podsights', '2022-02-16', null, 0.8, WDP('P355')], ['PARENT_OF', 'Findaway', '2022-06-01', null, 0.8, WDP('P355')]],
529 + adyen: [['PARENT_OF', 'Adyen Bank N.V.', null, null, 0.8, WDP('P355')], ['COMPETITOR', 'stripe', null, null, 0.7, { source: 'registry' }]],
530 + arm: [['SUBSIDIARY_OF', 'SoftBank Group', '2016-09-05', null, 0.98, WDP('P749')], ['OWNED_BY', 'SoftBank Group', '2016-09-05', null, 0.95, WDP('P127')], ['PARENT_OF', 'Arm China', '2018-04-01', null, 0.7, WDP('P355')], ['COMPETITOR', 'nvidia', null, null, 0.5, { source: 'registry' }]],
531 + tsmc: [['PARENT_OF', 'TSMC Arizona', '2020-05-15', null, 0.95, WDP('P355')], ['PARENT_OF', 'WaferTech', '1996-06-01', null, 0.9, WDP('P355')], ['OWNER_OF', 'ESMC (European Semiconductor Manufacturing Company)', '2023-08-08', null, 0.85, WDP('P1830')], ['OWNER_OF', 'Vanguard International Semiconductor', '1994-12-01', null, 0.8, WDP('P1830')], ['PARTNER', 'nvidia', null, null, 0.7, { source: 'registry' }], ['PARTNER', 'apple', null, null, 0.7, { source: 'registry' }]],
532 + siemens: [['PARENT_OF', 'Siemens Healthineers', '2018-03-16', null, 0.95, WDP('P355')], ['PARENT_OF', 'Siemens Mobility', '2018-08-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'Siemens Financial Services', null, null, 0.85, WDP('P355')], ['PARENT_OF', 'Siemens Energy', '2020-04-01', '2020-09-28', 0.9, WDP('P355')], ['OWNER_OF', 'Siemens Energy', '2020-09-28', null, 0.8, WDP('P1830')], ['ACQUIRED', 'Altair Engineering', '2025-03-26', null, 0.9, WDP('P1830')], ['ACQUIRED', 'Brightly Software', '2022-06-27', null, 0.85, WDP('P1830')]],
533 + 'samsung-electronics': [['PARENT_OF', 'Harman International', '2017-03-10', null, 0.98, WDP('P355')], ['PARENT_OF', 'Samsung Display', '2012-04-01', null, 0.95, WDP('P355')], ['PARENT_OF', 'Samsung Electronics America', null, null, 0.9, WDP('P355')], ['PARENT_OF', 'Samsung Medison', '2011-04-01', null, 0.85, WDP('P355')], ['OWNED_BY', 'Samsung Life Insurance', null, null, 0.7, WDP('P127')], ['COMPETITOR', 'apple', null, null, 0.7, { source: 'registry' }]],
534 + stripe: [['PARENT_OF', 'Stripe Payments Europe, Ltd.', null, null, 0.85, WDP('P355')], ['PARENT_OF', 'Stripe Payments UK, Ltd.', null, null, 0.8, WDP('P355')], ['ACQUIRED', 'Paystack', '2020-10-15', null, 0.95, WDP('P1830')], ['ACQUIRED', 'Lemon Squeezy', '2024-07-26', null, 0.85, WDP('P1830')], ['ACQUIRED', 'Bridge', '2025-02-04', null, 0.85, WDP('P1830')], ['COMPETITOR', 'adyen', null, null, 0.7, { source: 'registry' }], ['COMPETITOR', 'block', null, null, 0.6, { source: 'registry' }]],
535 + shopify: [['ACQUIRED', 'Deliverr', '2022-07-08', '2023-05-04', 0.9, WDP('P1830')], ['ACQUIRED', 'Vantage Discovery', '2025-03-10', null, 0.7, WDP('P1830')], ['PARTNER', 'stripe', null, null, 0.6, { source: 'registry' }]],
536 + cohere: [['OWNED_BY', 'Nvidia (minority investor)', '2023-06-08', null, 0.5, { source: 'registry' }]],
537 + anthropic: [['OWNED_BY', 'Amazon (minority investor)', '2023-09-25', null, 0.6, { source: 'registry' }], ['OWNED_BY', 'Alphabet (minority investor)', '2023-02-03', null, 0.6, { source: 'registry' }]],
538 + nubank: [['PARENT_OF', 'Nu México', '2019-05-01', null, 0.9, WDP('P355')], ['PARENT_OF', 'Nu Colombia', '2020-09-01', null, 0.9, WDP('P355')], ['ACQUIRED', 'Easynvest', '2020-09-01', null, 0.85, WDP('P1830')]],
539 +};
540 +function relationsFor(c) {
541 + const rows = RELATIONS[c.slug];
542 + if (rows) return rows.map(([kind, who, from, to, confidence, provenance]) => {
543 + const other = companiesBySlug.get(who);
544 + return { kind, company: other ? { slug: other.slug, display_name: other.display_name, logo_url: other.profile?.logo_url ?? other.profile?.icon_url ?? null } : null, to_name: other ? null : who, valid_from: from, valid_to: to, confidence, provenance };
545 + });
546 + const peer = companies.find((o) => o !== c && o.industries[0] === c.industries[0]);
547 + return [{ kind: 'COMPETITOR', company: peer ? { slug: peer.slug, display_name: peer.display_name, logo_url: peer.profile?.logo_url ?? peer.profile?.icon_url ?? null } : null, to_name: peer ? null : 'Unnamed peer', valid_from: null, valid_to: null, confidence: 0.6, provenance: { source: 'registry' } }, { kind: 'PARTNER', company: null, to_name: pick(PARTNERS), valid_from: iso(NOW - 200 * DAY), valid_to: null, confidence: 0.8, provenance: { source: 'registry' } }];
548 +}
549 +
550 +// Wikidata-sourced executives (`source: 'wikidata'`), some overlapping the page-observed rows to exercise the merge.
551 +// [name, title, status, valid_from]
552 +const WD_PEOPLE = {
553 + nvidia: [['Jensen Huang', 'Chief Executive Officer, President and co-founder', 'listed', '1993-04-05'], ['Colette Kress', 'Chief Financial Officer', 'listed', '2013-09-01'], ['Chris Malachowsky', 'Co-founder', 'listed', '1993-04-05']],
554 + apple: [['Tim Cook', 'Chief Executive Officer', 'listed', '2011-08-24'], ['Arthur D. Levinson', 'Chairman of the board', 'listed', '2011-11-15'], ['Kevan Parekh', 'Chief Financial Officer', 'listed', '2025-01-01'], ['Luca Maestri', 'Chief Financial Officer', 'no_longer_listed', '2014-05-29'], ['Steve Jobs', 'Co-founder', 'no_longer_listed', '1976-04-01']],
555 + sony: [['Hiroki Totoki', 'President and Chief Executive Officer', 'listed', '2025-04-01'], ['Kenichiro Yoshida', 'Chairman', 'listed', '2025-04-01'], ['Lin Tao', 'Chief Financial Officer', 'listed', '2025-04-01'], ['Kazuo Hirai', 'Chief Executive Officer', 'no_longer_listed', '2012-04-01']],
556 + toyota: [['Koji Sato', 'President and Chief Executive Officer', 'listed', '2023-04-01'], ['Akio Toyoda', 'Chairman of the board', 'listed', '2023-04-01']],
557 + roche: [['Thomas Schinecker', 'Chief Executive Officer', 'listed', '2023-03-15'], ['Severin Schwan', 'Chairman of the board', 'listed', '2023-03-15']],
558 + sap: [['Christian Klein', 'Chief Executive Officer', 'listed', '2019-10-11'], ['Dominik Asam', 'Chief Financial Officer', 'listed', '2023-03-07'], ['Hasso Plattner', 'Co-founder and Chairman of the supervisory board', 'no_longer_listed', '2003-05-01']],
559 + spotify: [['Daniel Ek', 'Chief Executive Officer and co-founder', 'listed', '2006-04-23'], ['Martin Lorentzon', 'Co-founder and Chairman', 'listed', '2006-04-23']],
560 + adyen: [['Pieter van der Does', 'Co-founder and co-CEO', 'listed', '2006-01-01'], ['Ingo Uytdehaage', 'Co-CEO', 'listed', '2023-05-01']],
561 + arm: [['Rene Haas', 'Chief Executive Officer', 'listed', '2022-02-08'], ['Masayoshi Son', 'Chairman of the board', 'listed', '2016-09-05']],
562 + tsmc: [['C. C. Wei', 'Chairman and Chief Executive Officer', 'listed', '2024-06-04'], ['Morris Chang', 'Founder', 'no_longer_listed', '1987-02-21']],
563 + siemens: [['Roland Busch', 'President and Chief Executive Officer', 'listed', '2021-02-03'], ['Ralf P. Thomas', 'Chief Financial Officer', 'listed', '2013-09-18']],
564 + 'samsung-electronics': [['Jun Young-hyun', 'Vice Chairman and co-CEO', 'listed', '2024-11-27'], ['Roh Tae-moon', 'Acting head, Device eXperience division', 'listed', '2025-03-01']],
565 + stripe: [['Patrick Collison', 'Chief Executive Officer and co-founder', 'listed', '2010-01-01'], ['John Collison', 'President and co-founder', 'listed', '2010-01-01']],
566 + shopify: [['Tobias Lütke', 'Chief Executive Officer and founder', 'listed', '2008-01-01'], ['Harley Finkelstein', 'President', 'listed', '2020-09-01']],
567 + cohere: [['Aidan Gomez', 'Chief Executive Officer and co-founder', 'listed', '2019-01-01'], ['Nick Frosst', 'Co-founder', 'listed', '2019-01-01'], ['Ivan Zhang', 'Co-founder', 'listed', '2019-01-01']],
568 + anthropic: [['Dario Amodei', 'Chief Executive Officer and co-founder', 'listed', '2021-01-01'], ['Daniela Amodei', 'President and co-founder', 'listed', '2021-01-01']],
569 + 'mistral-ai': [['Arthur Mensch', 'Chief Executive Officer and co-founder', 'listed', '2023-04-28'], ['Guillaume Lample', 'Chief Scientist and co-founder', 'listed', '2023-04-28'], ['Timothée Lacroix', 'Chief Technology Officer and co-founder', 'listed', '2023-04-28']],
570 + nubank: [['David Vélez', 'Chief Executive Officer and founder', 'listed', '2013-05-06'], ['Cristina Junqueira', 'Co-founder', 'listed', '2013-05-06']],
571 +};
572 +function wikidataPeople(c) {
573 + const rows = WD_PEOPLE[c.slug] ?? [];
574 + const url = c.profile?.wikidata_url ?? `https://www.wikidata.org/wiki/Special:Search?search=${encodeURIComponent(c.display_name)}`;
575 + return rows.map(([name, title, status, from]) => ({ id: id('per'), name, title, role_category: /chair/i.test(title) ? 'board' : 'c_suite', is_executive: true, first_seen_at: iso(new Date(from).getTime()), last_seen_at: RETRIEVED, removed_at: status === 'listed' ? null : iso(NOW - ri(200, 2000) * DAY), status, source_url: url, source: 'wikidata' }));
576 +}
577 +
578 +/** Self-hosted sample logos (plate + monogram) so QA never depends on the network; `icon` is the round variant. */
579 +function logoSvg(slug, round) {
580 + const name = companiesBySlug.get(slug)?.display_name ?? slug;
581 + const words = name.split(/\s+/).filter(Boolean);
582 + const mono = (words.length > 1 ? words[0][0] + words[1][0] : name.slice(0, 1)).toUpperCase();
583 + let h = 0;
584 + for (const ch of slug) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
585 + const hue = h % 360;
586 + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="${round ? 32 : 10}" fill="hsl(${hue} 55% 42%)"/><text x="32" y="40" font-family="ui-monospace, Menlo, monospace" font-size="${mono.length > 1 ? 24 : 30}" font-weight="700" fill="#fff" text-anchor="middle">${mono}</text></svg>`;
587 +}
588 +
226 589 // ------------------------------------------------------------------------------------------------ build dataset
227 590 const companies = [];
228 591 const companiesBySlug = new Map();
@@ -417,8 +780,10 @@ function buildCompany(row, idx) {
417 780 for (let i = 0; i < ri(5, 12); i++) {
418 781 const removed = chance(0.2);
419 782 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` });
783 + 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`, source: 'page' });
421 784 }
785 + // a couple of Wikidata executives also appear on the monitored page (exercise the name merge → both chips)
786 + for (const [pname, ptitle] of (WD_PEOPLE[slug] ?? []).slice(0, 1)) people.unshift({ id: id('per'), name: pname, title: ptitle.split(' and ')[0], role_category: 'c_suite', is_executive: true, first_seen_at: iso(created + 5 * DAY), last_seen_at: iso(NOW - DAY), removed_at: null, status: 'listed', source_url: `https://${domain}/about/leadership`, source: 'page' });
422 787 const products = [];
423 788 for (let i = 0; i < ri(3, 10); i++) {
424 789 const removed = chance(0.15);
@@ -503,6 +868,7 @@ function buildCompany(row, idx) {
503 868 founded_year: founded,
504 869 employees_band: band,
505 870 logo_url: null,
871 + profile: null, // filled after all companies exist (relationships reference peers)
506 872 status: chance(0.94) ? 'ACTIVE' : 'POSSIBLY_INACTIVE',
507 873 onboarding_status: 'active',
508 874 importance,
@@ -534,6 +900,14 @@ function buildCompany(row, idx) {
534 900 }
535 901
536 902 SEED.forEach((row, i) => buildCompany(row, i));
903 +for (const c of companies) c.profile = NULL_PROFILE.has(c.slug) || NO_PROFILE_KEY.has(c.slug) ? makeProfile(c, null) : PROFILES[c.slug] ? makeProfile(c, PROFILES[c.slug]) : autoProfile(c);
904 +const NO_CATALOGUE = new Set(['anthropic', 'mistral-ai']); // no monitored catalogue surface → Products tab falls back to Wikidata products
905 +for (const c of companies) {
906 + c._relationships = relationsFor(c);
907 + c._facts = factsOf(c);
908 + c._people.push(...wikidataPeople(c));
909 + if (NO_CATALOGUE.has(c.slug)) c._products = [];
910 +}
537 911
538 912 // events
539 913 function makeEvent(c, template, t, opts = {}) {
@@ -758,7 +1132,8 @@ function rankings(kind, window, country, industry, limit) {
758 1132 }
759 1133
760 1134 const pub = (c) => {
761 − const { _lat, _lon, _created, _sensors, _jobs, _people, _products, _plans, _locations, _news, _series, _hir90, _signals, _aliases, ...rest } = c;
1135 + const { _lat, _lon, _created, _sensors, _jobs, _people, _products, _plans, _locations, _news, _series, _hir90, _signals, _aliases, _relationships, _facts, ...rest } = c;
1136 + if (NO_PROFILE_KEY.has(c.slug)) delete rest.profile; // API builds that predate the profile field
762 1137 return rest;
763 1138 };
764 1139 const pubSensor = (s) => {
@@ -870,6 +1245,14 @@ const server = createServer(async (req, res) => {
870 1245 const admin = req.headers['x-ca-admin-token'];
871 1246
872 1247 try {
1248 + // ---- mock assets (sample logos; not part of the API contract)
1249 + if (seg[0] === '_mock' && (seg[1] === 'logo' || seg[1] === 'icon') && seg[2]) {
1250 + const slug = seg[2].replace(/\.svg$/, '');
1251 + if (!companiesBySlug.has(slug)) return notFound(res, 'logo not found');
1252 + res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600', 'access-control-allow-origin': '*' });
1253 + return res.end(logoSvg(slug, seg[1] === 'icon'));
1254 + }
1255 +
873 1256 // ---- platform
874 1257 if (path === '/stats') return json(res, 200, stats(), { 'cache-control': 'public, max-age=60' });
875 1258 if (path === '/stats/history') return json(res, 200, { items: history.slice(-clamp(Number(q.get('days') ?? 90), 1, 365)) });
@@ -952,7 +1335,7 @@ const server = createServer(async (req, res) => {
952 1335 const sub = seg[2];
953 1336 const list = perCompany.get(c.slug) ?? [];
954 1337 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 } });
1338 + 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: c._relationships, facts: c._facts, 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 1339 }
957 1340 if (sub === 'events') return json(res, 200, paginate(filterEvents(list, q), q));
958 1341 if (sub === 'timeline') {
modified apps/web/qa/screens.mjs +7 −0
@@ -54,6 +54,13 @@ const PAGES = [
54 54 '/company/stripe?tab=leadership',
55 55 '/company/stripe?tab=sources',
56 56 '/company/stripe?tab=history',
57 + '/company/nvidia',
58 + '/company/sony',
59 + '/company/sony?tab=leadership',
60 + '/company/cohere',
61 + '/company/cabify',
62 + '/company/anthropic?tab=products',
63 + '/company/compass',
57 64 '/company/compare?companies=stripe,adyen,block',
58 65 '/company/compare',
59 66 '/events',
modified apps/web/src/app/company/[slug]/layout.tsx +2 −1
@@ -18,7 +18,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
18 18 const { slug } = await params;
19 19 const c = await load(slug);
20 20 if (!c) return { title: 'Company' };
21 − const desc = `${c.display_name} (${c.canonical_domain}${c.country ? `, ${countryName(c.country)}` : ''}): ${c.counts.sensors} active sensors, ${c.counts.events} structured events, ${c.counts.changes} historical changes. ${c.description ?? ''}`.trim();
21 + const about = c.profile?.description ?? c.description ?? '';
22 + const desc = `${c.display_name} (${c.canonical_domain}${c.country ? `, ${countryName(c.country)}` : ''}): ${c.counts.sensors} active sensors, ${c.counts.events} structured events, ${c.counts.changes} historical changes. ${about.length > 220 ? `${about.slice(0, 219).trimEnd()}…` : about}`.trim();
22 23 return {
23 24 title: `${c.display_name} — sensors, timeline and history`,
24 25 description: desc,
modified apps/web/src/app/company/[slug]/page.tsx +9 −23
@@ -9,6 +9,7 @@ import { CompanyHeader, DensityStrip } from '@/components/company/company-header
9 9 import { CompanyMiniList } from '@/components/company/company-table';
10 10 import { MetricTiles } from '@/components/company/metric-tiles';
11 11 import { ConfidenceLegend, HistoryPanel, JobsPanel, LocationsPanel, PeoplePanel, PricingPanel, ProductsPanel, SensorsTable, SignalsPanel, TimelinePanel } from '@/components/company/panels';
12 +import { CorporateStructurePanel, KeyFactsPanel } from '@/components/company/profile-panels';
12 13 import { EventList } from '@/components/events/event-row';
13 14 import { Chip } from '@/components/ui/badges';
14 15 import { KV, Row } from '@/components/ui/key-value';
@@ -17,6 +18,7 @@ import { TabPanel, Tabs } from '@/components/ui/tabs';
17 18 import { api, ApiError, safe } from '@/lib/api';
18 19 import { fmtDate, fmtPct, humanize } from '@/lib/format';
19 20 import { bool, str, type SP } from '@/lib/params';
21 +import { sourceFor } from '@/lib/profile';
20 22 import { routes, SURFACE_LABELS } from '@/lib/site';
21 23 import type { CompanyDetail, MapBucket } from '@/lib/types';
22 24
@@ -48,6 +50,10 @@ export default async function CompanyPage({ params, searchParams }: { params: Pr
48 50 const requested = str(sp.tab) ?? 'overview';
49 51 const tab = TABS.some((t) => t.id === requested) ? requested : 'overview';
50 52 const tabs = TABS.map((t) => ({ ...t, count: t.id === 'jobs' ? c.counts.jobs_open : t.id === 'sources' ? c.counts.sensors : t.id === 'signals' ? c.signals?.length || undefined : undefined }));
53 + // Products tab fallback: Wikidata "product or material produced" statements, with the provenance of that field.
54 + const wdProducts = c.profile?.products ?? [];
55 + const wdSrc = sourceFor(c.profile, 'products');
56 + const wdSource = wdSrc ? { url: wdSrc.url, retrieved_at: wdSrc.retrieved_at } : c.profile?.wikidata_url ? { url: c.profile.wikidata_url, retrieved_at: c.profile.enriched_at ?? '' } : null;
51 57
52 58 return (
53 59 <Container wide>
@@ -61,7 +67,7 @@ export default async function CompanyPage({ params, searchParams }: { params: Pr
61 67 {tab === 'timeline' && <TimelinePanel slug={c.slug} data={await safe(api.companyTimeline(c.slug, str(sp.filter) ?? 'all'))} filter={str(sp.filter) ?? 'all'} />}
62 68 {tab === 'signals' && <SignalsPanel signals={c.signals ?? []} />}
63 69 {tab === 'jobs' && <JobsPanel slug={c.slug} data={await safe(api.companyJobs(c.slug, { status: str(sp.status) ?? 'open', ai: bool(sp.ai) ? 1 : undefined, per_page: 50, page: str(sp.page) }))} status={str(sp.status) ?? 'open'} ai={bool(sp.ai)} />}
64 − {tab === 'products' && <ProductsPanel data={await safe(api.companyProducts(c.slug))} />}
70 + {tab === 'products' && <ProductsPanel data={await safe(api.companyProducts(c.slug))} wikidataProducts={wdProducts} wikidataSource={wdSource} />}
65 71 {tab === 'pricing' && <PricingPanel data={await safe(api.companyPricing(c.slug))} />}
66 72 {tab === 'locations' && <Locations slug={c.slug} />}
67 73 {tab === 'leadership' && <PeoplePanel data={await safe(api.companyPeople(c.slug))} />}
@@ -88,6 +94,7 @@ async function Overview({ c }: { c: CompanyDetail }) {
88 94 return (
89 95 <div className="grid gap-8 lg:grid-cols-12">
90 96 <div className="space-y-8 lg:col-span-8">
97 + <KeyFactsPanel c={c} />
91 98 <section>
92 99 <p className="eyebrow mb-2">Latest structured events</p>
93 100 {events ? <EventList events={events.items} variant="table" showCompany={false} emptyLabel="No structured events detected for this company yet — sensors are attached and observing." /> : <Empty title="Events temporarily unavailable." />}
@@ -150,28 +157,7 @@ async function Overview({ c }: { c: CompanyDetail }) {
150 157 )}
151 158 </KV>
152 159 </section>
153 − {c.relationships?.length > 0 && (
154 − <section>
155 − <p className="eyebrow mb-2">Relationships</p>
156 − <ul className="divide-y divide-rule border-y border-rule text-sm">
157 − {c.relationships.map((r, i) => (
158 − <li key={i} className="flex flex-wrap items-center gap-2 py-1.5">
159 − <Chip>{r.kind.toLowerCase().replace(/_/g, ' ')}</Chip>
160 − {r.company ? (
161 − <Link href={routes.company(r.company.slug)} className="hover:text-accent">
162 − {r.company.display_name}
163 − </Link>
164 − ) : (
165 − <span>{r.to_name ?? '—'}</span>
166 − )}
167 − <span className="ml-auto text-[11px] text-ink-3">
168 − {r.valid_from ? `since ${fmtDate(r.valid_from)} · ` : ''}confidence {Math.round(r.confidence * 100)} %
169 − </span>
170 − </li>
171 − ))}
172 − </ul>
173 − </section>
174 − )}
160 + <CorporateStructurePanel relationships={c.relationships ?? []} />
175 161 <section>
176 162 <p className="eyebrow mb-2">Sensors by surface</p>
177 163 {surfaces.length ? <Bars dense rows={surfaces.map(([s, n]) => ({ key: s, label: SURFACE_LABELS[s] ?? s, value: n }))} /> : <Empty compact />}
modified apps/web/src/app/company/compare/page.tsx +4 −1
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { LineChart } from '@/components/charts/line-chart';
4 4 import { Sparkline } from '@/components/charts/sparkline';
5 +import { CompanyLogo } from '@/components/company/company-logo';
5 6 import { ComparePicker } from '@/components/company/compare-picker';
6 7 import { CountryChip, EventTypeBadge } from '@/components/ui/badges';
7 8 import { Container, Empty, Note, PageHeader, Section } from '@/components/ui/section';
@@ -10,6 +11,7 @@ import { cn } from '@/lib/cn';
10 11 import { EVENT_TYPES } from '@/lib/event-styles';
11 12 import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
12 13 import { str, type SP } from '@/lib/params';
14 +import { logoCandidates } from '@/lib/profile';
13 15 import { METRIC_LABELS, routes } from '@/lib/site';
14 16
15 17 export const metadata: Metadata = { title: 'Compare companies', description: 'Side-by-side activity, hiring momentum, product velocity, AI adoption, locations and events for 2–6 monitored companies.' };
@@ -48,7 +50,8 @@ export default async function ComparePage({ searchParams }: { searchParams: Prom
48 50 <th>Metric</th>
49 51 {cols.map((c) => (
50 52 <th key={c.slug} className="num">
51 − <Link href={routes.company(c.slug)} className="row-link">
53 + <Link href={routes.company(c.slug)} className="row-link inline-flex items-center gap-1.5">
54 + <CompanyLogo name={c.display_name} candidates={logoCandidates(c)} size={20} rounded="sm" />
52 55 {c.display_name}
53 56 </Link>
54 57 <span className="block font-normal normal-case tracking-normal text-ink-3">
modified apps/web/src/components/company/company-header.tsx +82 −34
@@ -1,52 +1,100 @@
1 −import { ExternalLink, GitCompareArrows } from 'lucide-react';
1 +import { ArrowUpRight, ExternalLink, GitCompareArrows } from 'lucide-react';
2 2 import Link from 'next/link';
3 3 import { Chip, CompanyTierBadge, CountryChip, StatusBadge } from '@/components/ui/badges';
4 4 import { LiveAgo } from '@/components/ui/live';
5 5 import { countryName } from '@/lib/countries';
6 6 import { fmtInt, fmtPct, plural } from '@/lib/format';
7 +import { descriptionOf, logoCandidates, socialsOf } from '@/lib/profile';
7 8 import { routes } from '@/lib/site';
8 9 import type { CompanyDetail } from '@/lib/types';
10 +import { CompanyLogo } from './company-logo';
11 +import { DescriptionAttribution } from './profile-panels';
9 12 import { WatchButton } from './watch-button';
10 13
11 −/** Company identity block: name, domain, ISO chip, industries, ticker, tier, status, watch + compare. */
14 +/**
15 + * Company identity block: logo (profile logo → icon → monogram), name + legal name + legal form, domain / listing chips,
16 + * HQ, founded, employees (sourced count when known, else the stated band), industries, attributed description, socials,
17 + * watch + compare. Everything shown comes from the card or its enrichment profile — nothing is inferred here.
18 + */
12 19 export function CompanyHeader({ c }: { c: CompanyDetail }) {
20 + const p = c.profile ?? null;
21 + const desc = descriptionOf(c);
22 + const socials = socialsOf(p);
23 + const founded = p?.founded_year ?? c.founded_year;
24 + const hqCity = p?.hq?.city ?? c.hq_city;
25 + const hqRegion = p?.hq?.region ?? c.hq_region;
26 + const ticker = p?.ticker ?? c.ticker;
27 + const exchange = p?.exchange ?? c.exchange;
28 + const isPublic = p?.public_company || c.public_company;
29 + const legalLine = [c.legal_name && c.legal_name !== c.display_name ? c.legal_name : null, p?.legal_form].filter(Boolean).join(' · ');
13 30 return (
14 − <div className="pb-4 pt-6 md:pt-8">
31 + <div className="pb-4 pt-5 md:pt-8" data-company-header>
15 32 <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
16 − <div className="min-w-0">
17 − <div className="eyebrow flex flex-wrap items-center gap-2">
18 − <span>Company</span>
19 − <CompanyTierBadge tier={c.tier} />
20 − {c.status !== 'ACTIVE' && <StatusBadge status={c.status} />}
21 − {c.public_company && c.ticker && (
22 − <span className="mono normal-case tracking-normal text-ink-2">
23 − {c.ticker}
24 − {c.exchange ? ` · ${c.exchange}` : ''}
33 + <div className="flex min-w-0 gap-3 md:gap-4">
34 + <CompanyLogo name={c.display_name} candidates={logoCandidates(c)} size={64} priority rounded="lg" className="mt-1 md:mt-1.5" />
35 + <div className="min-w-0 flex-1">
36 + <div className="eyebrow flex flex-wrap items-center gap-2">
37 + <span>Company</span>
38 + <CompanyTierBadge tier={c.tier} />
39 + {c.status !== 'ACTIVE' && <StatusBadge status={c.status} />}
40 + </div>
41 + <h1 className="display mt-1.5 text-[28px] md:text-[40px]">{c.display_name}</h1>
42 + {legalLine && <p className="mt-1 text-[13px] text-ink-3">{legalLine}</p>}
43 + <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1.5 text-sm text-ink-2">
44 + <a href={p?.official_website ?? c.website} target="_blank" rel="noopener noreferrer" className="mono inline-flex h-6 items-center gap-1 rounded-[3px] border border-rule px-1.5 text-xs text-ink-2 hover:border-rule-strong hover:text-accent">
45 + {c.canonical_domain} <ExternalLink className="size-3" aria-hidden />
46 + </a>
47 + {isPublic && ticker && (
48 + <Chip tone="outline" className="mono h-6 font-normal text-ink-2" title="Listed company — ticker and exchange as recorded">
49 + {ticker}
50 + {exchange ? <span className="text-ink-3">· {exchange}</span> : null}
51 + </Chip>
52 + )}
53 + <span className="inline-flex items-center gap-1.5">
54 + <CountryChip code={c.country} name={countryName(c.country)} />
55 + {hqCity && (
56 + <span>
57 + {hqCity}
58 + {hqRegion ? `, ${hqRegion}` : ''}
59 + </span>
60 + )}
25 61 </span>
62 + {founded && <span className="tnum">est. {founded}</span>}
63 + {typeof p?.employees === 'number' ? (
64 + <span className="tnum">
65 + {fmtInt(p.employees)} employees{p.employees_year ? ` (${p.employees_year})` : ''}
66 + </span>
67 + ) : (
68 + c.employees_band && <span className="tnum">{c.employees_band} employees (stated)</span>
69 + )}
70 + </div>
71 + <div className="mt-2 flex flex-wrap gap-1.5">
72 + {c.industries.map((i) => (
73 + <Link key={i} href={routes.industry(i)}>
74 + <Chip tone="neutral" className="hover:bg-surface-3">
75 + {i.replace(/-/g, ' ')}
76 + </Chip>
77 + </Link>
78 + ))}
79 + </div>
80 + {desc && (
81 + <div className="mt-3 max-w-2xl">
82 + <p className="text-[15px] leading-relaxed text-ink-2">{desc.text}</p>
83 + <DescriptionAttribution c={c} className="mt-1" />
84 + </div>
85 + )}
86 + {socials.length > 0 && (
87 + <ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Official social profiles" data-socials>
88 + {socials.map((s) => (
89 + <li key={s.key}>
90 + <a href={s.url} target="_blank" rel="noopener nofollow noreferrer" className="inline-flex h-7 items-center gap-1 rounded-[3px] border border-rule px-2 text-[12px] text-ink-2 hover:border-rule-strong hover:text-ink" title={s.url}>
91 + {s.label} <ArrowUpRight className="size-3 text-ink-3" aria-hidden />
92 + </a>
93 + </li>
94 + ))}
95 + </ul>
26 96 )}
27 97 </div>
28 − <h1 className="display mt-2 text-[28px] md:text-[40px]">{c.display_name}</h1>
29 − <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-ink-2">
30 − <a href={c.website} target="_blank" rel="noopener noreferrer" className="mono inline-flex items-center gap-1 text-ink-2 hover:text-accent">
31 − {c.canonical_domain} <ExternalLink className="size-3" aria-hidden />
32 − </a>
33 − <span className="inline-flex items-center gap-1.5">
34 − <CountryChip code={c.country} name={countryName(c.country)} />
35 − {c.hq_city && <span>{c.hq_city}</span>}
36 − </span>
37 − {c.founded_year && <span className="tnum">est. {c.founded_year}</span>}
38 − {c.employees_band && <span className="tnum">{c.employees_band} employees (stated)</span>}
39 − </div>
40 − <div className="mt-2 flex flex-wrap gap-1.5">
41 − {c.industries.map((i) => (
42 − <Link key={i} href={routes.industry(i)}>
43 − <Chip tone="neutral" className="hover:bg-surface-3">
44 − {i.replace(/-/g, ' ')}
45 − </Chip>
46 − </Link>
47 − ))}
48 − </div>
49 − {c.description && <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">{c.description}</p>}
50 98 </div>
51 99 <div className="flex shrink-0 flex-wrap items-center gap-2 md:flex-col md:items-end">
52 100 <div className="flex gap-2">
added apps/web/src/components/company/company-logo.tsx +29 −0
@@ -0,0 +1,29 @@
1 +'use client';
2 +import { useState } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { monogram } from '@/lib/profile';
5 +
6 +/**
7 + * Company logo with graceful degradation: tries each candidate URL in order (profile logo → icon → legacy), falls back
8 + * to a monogram plate. Plain `<img>` (remote hosts are unknown, so `next/image` optimisation is not used); lazy by
9 + * default, `referrerPolicy="no-referrer"` so third-party hosts do not learn which profile was viewed.
10 + */
11 +export function CompanyLogo({ name, candidates, size = 40, className, priority = false, rounded = 'md' }: { name: string; candidates: string[]; size?: number; className?: string; priority?: boolean; rounded?: 'sm' | 'md' | 'lg' }) {
12 + const [idx, setIdx] = useState(0);
13 + const src = candidates[idx];
14 + const radius = rounded === 'sm' ? 'rounded-[3px]' : rounded === 'lg' ? 'rounded-[10px]' : 'rounded-[6px]';
15 + const style = { width: size, height: size };
16 + if (!src) {
17 + return (
18 + <span className={cn('mono inline-flex shrink-0 select-none items-center justify-center border border-rule bg-surface-2 font-semibold text-ink-2', radius, className)} style={{ ...style, fontSize: Math.max(10, Math.round(size * 0.38)) }} aria-hidden data-logo="monogram">
19 + {monogram(name)}
20 + </span>
21 + );
22 + }
23 + return (
24 + <span className={cn('inline-flex shrink-0 items-center justify-center overflow-hidden border border-rule bg-white', radius, className)} style={style} data-logo="image">
25 + {/* eslint-disable-next-line @next/next/no-img-element */}
26 + <img src={src} alt="" width={size} height={size} loading={priority ? 'eager' : 'lazy'} decoding="async" referrerPolicy="no-referrer" className="h-full w-full object-contain p-[12%]" onError={() => setIdx((i) => i + 1)} />
27 + </span>
28 + );
29 +}
modified apps/web/src/components/company/company-table.tsx +27 −11
@@ -4,14 +4,17 @@ import { CompanyTierBadge, CountryChip } from '@/components/ui/badges';
4 4 import { LiveAgo } from '@/components/ui/live';
5 5 import { cn } from '@/lib/cn';
6 6 import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
7 +import { descriptionOf, logoCandidates } from '@/lib/profile';
7 8 import { routes } from '@/lib/site';
8 9 import type { CompanyCard } from '@/lib/types';
10 +import { CompanyLogo } from './company-logo';
9 11
10 12 /**
11 − * Directory rows: dense table on ≥ md (name · country · industry · activity · hiring 30d · AI · events · sensors · sparkline ·
12 − * last event) and stacked cards below. Missing metrics render as a dash, never 0.
13 + * Directory rows: dense table on ≥ md (logo · name + domain + one-line description · country · industry · activity ·
14 + * hiring 30d · AI · events · sensors · sparkline · last event) and stacked cards below. Missing metrics render as a dash,
15 + * never 0. Logos are lazy with a monogram fallback; the description is the attributed profile paragraph when present.
13 16 */
14 −export function CompanyTable({ items, className, rank = false, showSparkline = true, valueLabel, formatValue, formatDelta }: { items: (CompanyCard & { rank?: number; value?: number; delta?: number | null })[]; className?: string; rank?: boolean; showSparkline?: boolean; valueLabel?: string; formatValue?: (c: CompanyCard & { value?: number }) => string; formatDelta?: (c: CompanyCard & { delta?: number | null }) => string | null }) {
17 +export function CompanyTable({ items, className, rank = false, showSparkline = true, showDescription = true, valueLabel, formatValue, formatDelta }: { items: (CompanyCard & { rank?: number; value?: number; delta?: number | null })[]; className?: string; rank?: boolean; showSparkline?: boolean; showDescription?: boolean; valueLabel?: string; formatValue?: (c: CompanyCard & { value?: number }) => string; formatDelta?: (c: CompanyCard & { delta?: number | null }) => string | null }) {
15 18 if (!items.length) return <p className="border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">No companies match these filters yet.</p>;
16 19 return (
17 20 <div className={className} data-company-table>
@@ -39,16 +42,27 @@ export function CompanyTable({ items, className, rank = false, showSparkline = t
39 42 {items.map((c) => {
40 43 const h = num(c.metrics.hiring_momentum_30d);
41 44 const d = formatDelta?.(c) ?? null;
45 + const desc = showDescription ? descriptionOf(c)?.text : null;
42 46 return (
43 47 <tr key={c.id}>
44 48 {rank && <td className="num tnum text-ink-3">{c.rank}</td>}
45 49 <td className="primary">
46 − <Link href={routes.company(c.slug)} className="row-link">
47 − <span className="inline-flex items-center gap-2">
48 − {c.display_name}
49 − <CompanyTierBadge tier={c.tier} />
50 + <Link href={routes.company(c.slug)} className="row-link flex items-center gap-2.5">
51 + <CompanyLogo name={c.display_name} candidates={logoCandidates(c)} size={28} rounded="sm" />
52 + <span className="min-w-0">
53 + <span className="inline-flex items-center gap-2">
54 + {c.display_name}
55 + <CompanyTierBadge tier={c.tier} />
56 + </span>
57 + <span className="flex max-w-[30rem] items-baseline gap-1.5 text-[11px] font-normal text-ink-3 lg:max-w-[38rem]">
58 + <span className="mono shrink-0">{c.canonical_domain}</span>
59 + {desc && (
60 + <span className="truncate" title={desc}>
61 + · {desc}
62 + </span>
63 + )}
64 + </span>
50 65 </span>
51 − <span className="mono block text-[11px] font-normal text-ink-3">{c.canonical_domain}</span>
52 66 </Link>
53 67 </td>
54 68 <td>
@@ -85,6 +99,7 @@ export function CompanyTable({ items, className, rank = false, showSparkline = t
85 99 <Link href={routes.company(c.slug)} className="block py-3">
86 100 <div className="flex items-start gap-3">
87 101 {rank && <span className="tnum w-6 shrink-0 pt-0.5 text-sm text-ink-3">{c.rank}</span>}
102 + <CompanyLogo name={c.display_name} candidates={logoCandidates(c)} size={32} rounded="sm" className="mt-0.5" />
88 103 <div className="min-w-0 flex-1">
89 104 <p className="flex items-center gap-2 text-[15px] font-medium text-ink">
90 105 <span className="truncate">{c.display_name}</span>
@@ -131,7 +146,7 @@ export function CompanyTable({ items, className, rank = false, showSparkline = t
131 146 );
132 147 }
133 148
134 −/** Compact list of companies (name · country · one metric) for homepage sections and side panels. */
149 +/** Compact list of companies (logo · name · country · one metric) for homepage sections and side panels. */
135 150 export function CompanyMiniList({ items, metric = 'activity_score', label, format, className, sparkline = false }: { items: CompanyCard[]; metric?: keyof CompanyCard['metrics']; label?: string; format?: (v: number | null) => string; className?: string; sparkline?: boolean }) {
136 151 if (!items.length) return <p className="border border-dashed border-rule-strong px-4 py-6 text-center text-xs text-ink-3">No monitored evidence available yet.</p>;
137 152 const fmt = format ?? ((v: number | null) => (v === null ? '—' : metric.startsWith('hiring') ? fmtPctSigned(v) : fmtScore(v)));
@@ -141,8 +156,9 @@ export function CompanyMiniList({ items, metric = 'activity_score', label, forma
141 156 const v = num(c.metrics[metric]);
142 157 return (
143 158 <li key={c.id}>
144 − <Link href={routes.company(c.slug)} className="flex items-center gap-3 py-2 hover:text-accent">
145 − <span className="tnum w-5 shrink-0 text-xs text-ink-3">{i + 1}</span>
159 + <Link href={routes.company(c.slug)} className="flex items-center gap-2.5 py-2 hover:text-accent">
160 + <span className="tnum w-4 shrink-0 text-xs text-ink-3">{i + 1}</span>
161 + <CompanyLogo name={c.display_name} candidates={logoCandidates(c)} size={24} rounded="sm" />
146 162 <span className="min-w-0 flex-1">
147 163 <span className="block truncate text-[14px] font-medium text-ink">{c.display_name}</span>
148 164 <span className="mono block truncate text-[11px] text-ink-3">
modified apps/web/src/components/company/panels.tsx +72 −27
@@ -8,8 +8,10 @@ import { Empty, Note, Stat, StatGrid } from '@/components/ui/section';
8 8 import { cn } from '@/lib/cn';
9 9 import { countryName } from '@/lib/countries';
10 10 import { fmtDate, fmtDateTime, fmtDayLabel, fmtDuration, fmtInt, fmtPct, fmtPrice, fmtScore, humanize, pathOf, plural } from '@/lib/format';
11 +import { mergePeople, roleRank, type MergedPerson } from '@/lib/profile';
11 12 import { routes, SURFACE_LABELS, TIMELINE_FILTERS } from '@/lib/site';
12 13 import type { HistoryPayload, Job, JobsPage, Location, Person, Plan, Product, Sensor, Signal, TimelinePayload } from '@/lib/types';
14 +import { SourceChip, WikidataProducts } from './profile-panels';
13 15
14 16 /* ------------------------------------------------------------------------------------------------------------ timeline */
15 17 export function TimelinePanel({ slug, data, filter }: { slug: string; data: TimelinePayload | null; filter: string }) {
@@ -188,9 +190,18 @@ function JobsTable({ items }: { items: Job[] }) {
188 190 }
189 191
190 192 /* ------------------------------------------------------------------------------------------------------------ products */
191 −export function ProductsPanel({ data }: { data: { listed: Product[]; removed: Product[] } | null }) {
193 +export function ProductsPanel({ data, wikidataProducts = [], wikidataSource = null }: { data: { listed: Product[]; removed: Product[] } | null; wikidataProducts?: string[]; wikidataSource?: { url: string | null; retrieved_at: string } | null }) {
192 194 if (!data) return <Empty title="Products temporarily unavailable." />;
193 − if (!data.listed.length && !data.removed.length) return <Empty>No product catalog surface has been reconciled for this company yet.</Empty>;
195 + if (!data.listed.length && !data.removed.length) {
196 + if (wikidataProducts.length)
197 + return (
198 + <div className="space-y-4">
199 + <WikidataProducts products={wikidataProducts} source={wikidataSource} />
200 + <Empty compact title="No product catalogue surface has been reconciled for this company yet.">Once a catalogue page is monitored, listed products appear here with first-seen / last-seen dates.</Empty>
201 + </div>
202 + );
203 + return <Empty>No product catalog surface has been reconciled for this company yet.</Empty>;
204 + }
194 205 const Row = ({ p }: { p: Product }) => (
195 206 <li className="py-2.5">
196 207 <div className="flex flex-wrap items-center gap-2">
@@ -356,40 +367,74 @@ export function LocationsPanel({ data, map }: { data: { items: Location[]; count
356 367 }
357 368
358 369 /* ------------------------------------------------------------------------------------------------------------ leadership */
370 +/**
371 + * Page-observed people and Wikidata-sourced executives merged by name (both chips kept when they agree), CEO / chair /
372 + * founders first. The "no longer listed" group stays separate with wording that depends on the source.
373 + */
359 374 export function PeoplePanel({ data }: { data: { listed: Person[]; no_longer_listed: Person[] } | null }) {
360 375 if (!data) return <Empty title="Leadership temporarily unavailable." />;
361 − if (!data.listed.length && !data.no_longer_listed.length) return <Empty>No leadership page is monitored for this company yet.</Empty>;
362 − const Row = ({ p }: { p: Person }) => (
363 − <li className="flex flex-wrap items-center gap-2 py-2 text-sm">
364 − <span className="font-medium text-ink">{p.name}</span>
365 − <span className="text-ink-2">{p.title ?? '—'}</span>
366 − {p.is_executive && <Chip>executive</Chip>}
367 − <span className="ml-auto text-[11px] text-ink-3">
368 − {p.status === 'listed' ? `listed since ${fmtDate(p.first_seen_at)}` : `no longer listed since ${fmtDate(p.removed_at ?? p.last_seen_at)}`}
369 − {p.source_url && (
370 − <>
371 − {' · '}
372 − <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">
373 − source ↗
374 − </a>
375 − </>
376 − )}
377 − </span>
378 − </li>
379 − );
376 + if (!data.listed.length && !data.no_longer_listed.length) return <Empty>No leadership page is monitored for this company yet, and Wikidata records no current executives for it.</Empty>;
377 + const listed = mergePeople(data.listed);
378 + const gone = mergePeople(data.no_longer_listed);
379 + const nPage = listed.filter((p) => p.sources.includes('page')).length;
380 + const nWd = listed.filter((p) => p.sources.includes('wikidata')).length;
381 + const Row = ({ p }: { p: MergedPerson }) => {
382 + const onlyWd = p.sources.length === 1 && p.sources[0] === 'wikidata';
383 + const when = p.status === 'listed' ? (onlyWd ? `recorded on Wikidata · retrieved ${fmtDate(p.last_seen_at)}` : `listed since ${fmtDate(p.first_seen_at)}`) : onlyWd ? `no longer recorded as current on Wikidata · ${fmtDate(p.removed_at ?? p.last_seen_at)}` : `no longer listed since ${fmtDate(p.removed_at ?? p.last_seen_at)}`;
384 + return (
385 + <li className="flex flex-wrap items-center gap-x-2 gap-y-1 py-2 text-sm" data-person-source={p.sources.join('+')}>
386 + <span className="font-medium text-ink">{p.name}</span>
387 + <span className="text-ink-2">{p.title ?? 'title not stated'}</span>
388 + {p.is_executive && roleRank(p.title) > 3 && <Chip>executive</Chip>}
389 + {p.sources.map((s) => (
390 + <SourceChip key={s} source={s} url={s === 'page' ? p.source_url : null} retrievedAt={p.last_seen_at} />
391 + ))}
392 + <span className="ml-auto text-[11px] text-ink-3">
393 + {when}
394 + {p.source_url && !onlyWd && (
395 + <>
396 + {' · '}
397 + <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">
398 + source ↗
399 + </a>
400 + </>
401 + )}
402 + {p.source_url && onlyWd && (
403 + <>
404 + {' · '}
405 + <a href={p.source_url} target="_blank" rel="noopener nofollow noreferrer" className="hover:text-accent">
406 + statement ↗
407 + </a>
408 + </>
409 + )}
410 + </span>
411 + </li>
412 + );
413 + };
380 414 return (
381 415 <div className="space-y-6">
382 416 <div>
383 − <p className="eyebrow mb-1">Listed on the monitored leadership page ({data.listed.length})</p>
384 − <ul className="divide-y divide-rule border-y border-rule">{data.listed.map((p) => <Row key={p.id} p={p} />)}</ul>
417 + <p className="eyebrow mb-1 flex flex-wrap items-baseline gap-x-2">
418 + Currently listed <span className="tnum normal-case tracking-normal">({listed.length})</span>
419 + <span className="tnum font-normal normal-case tracking-normal text-ink-3">
420 + {nPage > 0 && `${nPage} on the monitored leadership page`}
421 + {nPage > 0 && nWd > 0 && ' · '}
422 + {nWd > 0 && `${nWd} recorded on Wikidata`}
423 + </span>
424 + </p>
425 + {listed.length ? <ul className="divide-y divide-rule border-y border-rule">{listed.map((p) => <Row key={p.id} p={p} />)}</ul> : <Empty compact title="No one is currently listed." />}
385 426 </div>
386 − {data.no_longer_listed.length > 0 && (
427 + {gone.length > 0 && (
387 428 <div>
388 − <p className="eyebrow mb-1">No longer listed ({data.no_longer_listed.length})</p>
389 − <ul className="divide-y divide-rule border-y border-rule">{data.no_longer_listed.map((p) => <Row key={p.id} p={p} />)}</ul>
429 + <p className="eyebrow mb-1">
430 + No longer listed <span className="tnum normal-case tracking-normal">({gone.length})</span>
431 + </p>
432 + <ul className="divide-y divide-rule border-y border-rule">{gone.map((p) => <Row key={p.id} p={p} />)}</ul>
390 433 </div>
391 434 )}
392 − <Note>Leadership data is limited to public professional context on the company’s own pages. A profile that disappears is “no longer listed on the monitored leadership page” — the platform never states why.</Note>
435 + <Note>
436 + Leadership data is limited to public professional context: the company’s own leadership page (observed by a sensor) and Wikidata position statements (with their retrieval time). A profile that disappears from the page is “no longer listed on the monitored leadership page”; a Wikidata position with an end date is “no longer recorded as current” — the platform never states why.
437 + </Note>
393 438 </div>
394 439 );
395 440 }
added apps/web/src/components/company/profile-panels.tsx +350 −0
@@ -0,0 +1,350 @@
1 +import { ArrowUpRight, ChevronDown, MapPin } from 'lucide-react';
2 +import Link from 'next/link';
3 +import type { ReactNode } from 'react';
4 +import { Chip } from '@/components/ui/badges';
5 +import { Empty, Note } from '@/components/ui/section';
6 +import { cn } from '@/lib/cn';
7 +import { countryName } from '@/lib/countries';
8 +import { fmtDate, fmtDateTime, fmtInt, fmtMoney, plural } from '@/lib/format';
9 +import { descriptionOf, groupRelationships, logoCandidates, relationshipName, sourceFor, sourceLabel } from '@/lib/profile';
10 +import { routes } from '@/lib/site';
11 +import type { CompanyCard, CompanyDetail, CompanyFact, MoneyFact, Relationship } from '@/lib/types';
12 +import { CompanyLogo } from './company-logo';
13 +
14 +/* ------------------------------------------------------------------------------------------------------- source chip */
15 +/** Small provenance chip; `title` carries the retrieval time so every fact is auditable on hover / long-press. */
16 +export function SourceChip({ source, url, retrievedAt, className }: { source: string | null | undefined; url?: string | null; retrievedAt?: string | null; className?: string }) {
17 + if (!source) return null;
18 + const label = sourceLabel(source);
19 + const title = retrievedAt ? `${label} · retrieved ${fmtDateTime(retrievedAt)}` : label;
20 + const llm = source.toLowerCase() === 'llm';
21 + const chip = (
22 + <Chip tone={llm ? 'warning' : 'outline'} title={title} className={cn('font-normal', className)}>
23 + {label}
24 + </Chip>
25 + );
26 + if (!url) return chip;
27 + return (
28 + <a href={url} target="_blank" rel="noopener nofollow noreferrer" className="inline-flex hover:opacity-80" aria-label={`${label} source (opens in a new tab)`}>
29 + {chip}
30 + </a>
31 + );
32 +}
33 +
34 +/* ------------------------------------------------------------------------------------------------ description credit */
35 +/** Attribution line under the description — the wording depends on where the paragraph came from. */
36 +export function DescriptionAttribution({ c, className }: { c: CompanyCard; className?: string }) {
37 + const d = descriptionOf(c);
38 + if (!d || !d.source) return null;
39 + const ext = { target: '_blank', rel: 'noopener nofollow noreferrer' } as const;
40 + if (d.source === 'wikipedia') {
41 + const license = d.license ?? 'CC BY-SA 4.0';
42 + return (
43 + <p className={cn('text-[11px] text-ink-3', className)}>
44 + Source:{' '}
45 + {d.url ? (
46 + <a href={d.url} {...ext} className="hover:text-accent">
47 + Wikipedia
48 + </a>
49 + ) : (
50 + 'Wikipedia'
51 + )}{' '}
52 + · {license}
53 + </p>
54 + );
55 + }
56 + if (d.source === 'wikidata') {
57 + return (
58 + <p className={cn('text-[11px] text-ink-3', className)}>
59 + Source:{' '}
60 + {d.url ? (
61 + <a href={d.url} {...ext} className="hover:text-accent">
62 + Wikidata
63 + </a>
64 + ) : (
65 + 'Wikidata'
66 + )}
67 + {d.license ? ` · ${d.license}` : ' · CC0'}
68 + </p>
69 + );
70 + }
71 + if (d.source === 'homepage') {
72 + return (
73 + <p className={cn('text-[11px] text-ink-3', className)}>
74 + {d.url ? (
75 + <a href={d.url} {...ext} className="hover:text-accent">
76 + From the company’s website
77 + </a>
78 + ) : (
79 + 'From the company’s website'
80 + )}
81 + </p>
82 + );
83 + }
84 + return (
85 + <p className={cn('flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3', className)}>
86 + <Chip tone="warning" className="font-normal">
87 + Generated from the company’s public pages
88 + </Chip>
89 + <span>Model-written summary of observed first-party text — not a statement by the company.</span>
90 + </p>
91 + );
92 +}
93 +
94 +/* ----------------------------------------------------------------------------------------------------- key facts */
95 +type FactRow = { key: string; label: ReactNode; value: ReactNode; source: string | null; url: string | null; retrievedAt: string | null };
96 +
97 +const MONEY_LABEL: Record<'revenue' | 'net_income' | 'total_assets', string> = { revenue: 'Revenue', net_income: 'Net income', total_assets: 'Total assets' };
98 +
99 +function money(m: MoneyFact | null): ReactNode {
100 + if (!m || typeof m.value !== 'number') return null;
101 + return (
102 + <span className="tnum">
103 + {fmtMoney(m.value, m.currency)} <span className="text-ink-3">· FY{m.year}</span>
104 + </span>
105 + );
106 +}
107 +
108 +/** Deterministic public registry links for identifiers (no lookup, no inference). */
109 +const EDGAR = (cik: string) => `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${encodeURIComponent(cik)}`;
110 +const GLEIF = (lei: string) => `https://search.gleif.org/#/record/${encodeURIComponent(lei)}`;
111 +
112 +/** Builds the sourced fact rows: structured profile fields first (typed formatting), then any extra `facts[]` entries. */
113 +export function buildFactRows(c: CompanyDetail): FactRow[] {
114 + const p = c.profile ?? null;
115 + const rows: FactRow[] = [];
116 + const covered = new Set<string>();
117 + const push = (key: string, label: ReactNode, value: ReactNode, fields: string[] = [key]) => {
118 + if (value === null || value === undefined || value === '' || value === false) return;
119 + const s = sourceFor(p, ...fields);
120 + const f = (c.facts ?? []).find((x) => x.key === key || fields.includes(x.key));
121 + rows.push({ key, label, value, source: s?.source ?? f?.source ?? null, url: s?.url ?? f?.url ?? null, retrievedAt: s?.retrieved_at ?? f?.retrieved_at ?? null });
122 + covered.add(key);
123 + fields.forEach((x) => covered.add(x));
124 + };
125 +
126 + if (p) {
127 + if (p.founded_year) push('founded_year', 'Founded', <span className="tnum">{p.founded_year}</span>, ['founded_year', 'founded', 'inception']);
128 + const hqParts = [p.hq?.city, p.hq?.region, p.hq?.country ? countryName(p.hq.country) : null].filter(Boolean) as string[];
129 + if (hqParts.length)
130 + push(
131 + 'hq',
132 + 'Headquarters',
133 + <span className="inline-flex flex-wrap items-center gap-x-1.5">
134 + <span>{hqParts.join(', ')}</span>
135 + <Link href={routes.company(c.slug, 'locations')} scroll={false} className="inline-flex items-center gap-0.5 text-[11px] text-ink-3 hover:text-accent" aria-label="Open the Locations tab">
136 + <MapPin className="size-3" aria-hidden /> map
137 + </Link>
138 + </span>,
139 + ['hq', 'headquarters', 'hq_city', 'hq_country'],
140 + );
141 + if (typeof p.employees === 'number')
142 + push(
143 + 'employees',
144 + 'Employees',
145 + <span className="tnum">
146 + {fmtInt(p.employees)}
147 + {p.employees_year ? <span className="text-ink-3"> · {p.employees_year}</span> : null}
148 + </span>,
149 + ['employees', 'employees_year'],
150 + );
151 + for (const k of ['revenue', 'net_income', 'total_assets'] as const) push(k, MONEY_LABEL[k], money(p[k]));
152 + if (p.legal_form) push('legal_form', 'Legal form', p.legal_form);
153 + if (p.public_company && (p.ticker || c.ticker))
154 + push(
155 + 'listing',
156 + 'Listing',
157 + <span className="mono text-xs">
158 + {p.ticker ?? c.ticker}
159 + {(p.exchange ?? c.exchange) && <span className="text-ink-3"> · {p.exchange ?? c.exchange}</span>}
160 + </span>,
161 + ['ticker', 'exchange', 'listing'],
162 + );
163 + if (p.isin) push('isin', 'ISIN', <span className="mono text-xs">{p.isin}</span>);
164 + if (p.lei)
165 + push(
166 + 'lei',
167 + 'LEI',
168 + <a href={GLEIF(p.lei)} target="_blank" rel="noopener nofollow noreferrer" className="mono inline-flex items-center gap-0.5 text-xs hover:text-accent">
169 + {p.lei} <ArrowUpRight className="size-3 text-ink-3" aria-hidden />
170 + </a>,
171 + );
172 + if (p.sec_cik)
173 + push(
174 + 'sec_cik',
175 + 'SEC CIK',
176 + <a href={EDGAR(p.sec_cik)} target="_blank" rel="noopener nofollow noreferrer" className="mono inline-flex items-center gap-0.5 text-xs hover:text-accent">
177 + {p.sec_cik} <ArrowUpRight className="size-3 text-ink-3" aria-hidden />
178 + </a>,
179 + ['sec_cik', 'cik'],
180 + );
181 + if (p.industry_labels?.length) push('industry_labels', 'Industry (Wikidata)', p.industry_labels.join(', '), ['industry_labels', 'industries']);
182 + if (p.phone) push('phone', 'Phone', <span className="mono text-xs">{p.phone}</span>);
183 + if (p.wikipedia_url || p.wikidata_url)
184 + push(
185 + 'references',
186 + 'References',
187 + <span className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
188 + {p.wikipedia_url && (
189 + <a href={p.wikipedia_url} target="_blank" rel="noopener nofollow noreferrer" className="link inline-flex items-center gap-0.5">
190 + Wikipedia <ArrowUpRight className="size-3" aria-hidden />
191 + </a>
192 + )}
193 + {p.wikidata_url && (
194 + <a href={p.wikidata_url} target="_blank" rel="noopener nofollow noreferrer" className="link inline-flex items-center gap-0.5">
195 + Wikidata <ArrowUpRight className="size-3" aria-hidden />
196 + </a>
197 + )}
198 + </span>,
199 + ['wikipedia_url', 'wikidata_url'],
200 + );
201 + }
202 + // extra flattened facts not already rendered from the structured profile
203 + for (const f of c.facts ?? []) {
204 + if (covered.has(f.key) || !f.value) continue;
205 + rows.push({ key: `fact:${f.key}`, label: f.label || f.key, value: f.value, source: f.source, url: f.url, retrievedAt: f.retrieved_at });
206 + covered.add(f.key);
207 + }
208 + return rows;
209 +}
210 +
211 +/** Overview → first block. Hides null facts; each row carries a source chip whose tooltip is the retrieval time. */
212 +export function KeyFactsPanel({ c, className }: { c: CompanyDetail; className?: string }) {
213 + const rows = buildFactRows(c);
214 + const enriched = c.profile?.enriched_at ?? null;
215 + return (
216 + <section className={className} data-key-facts>
217 + <div className="mb-2 flex items-baseline justify-between gap-2">
218 + <p className="eyebrow">Key facts</p>
219 + {enriched && <span className="text-[11px] text-ink-3">enriched {fmtDate(enriched)}</span>}
220 + </div>
221 + {rows.length === 0 ? (
222 + <Empty compact title="No sourced facts yet — enrichment pending.">
223 + Facts appear here once Wikidata, Wikipedia or registry lookups have been reconciled for this company.
224 + </Empty>
225 + ) : (
226 + <dl className="border-y border-rule text-sm">
227 + {rows.map((r) => (
228 + <div key={r.key} className="grid grid-cols-[6.5rem_minmax(0,1fr)_auto] items-baseline gap-x-3 border-b border-rule py-1.5 last:border-b-0 sm:grid-cols-[7.5rem_minmax(0,1fr)_auto]">
229 + <dt className="text-[13px] text-ink-3">{r.label}</dt>
230 + <dd className="min-w-0 break-words text-ink">{r.value}</dd>
231 + <dd className="shrink-0 self-center">
232 + <SourceChip source={r.source} url={r.url} retrievedAt={r.retrievedAt} />
233 + </dd>
234 + </div>
235 + ))}
236 + </dl>
237 + )}
238 + {rows.length > 0 && <Note className="mt-2">Facts are quoted from their source as retrieved; figures are not converted or adjusted, and a missing fact is simply not shown.</Note>}
239 + </section>
240 + );
241 +}
242 +
243 +/* ------------------------------------------------------------------------------------------- corporate structure */
244 +function validity(r: Relationship): string | null {
245 + if (r.valid_from && r.valid_to) return `${fmtDate(r.valid_from)} – ${fmtDate(r.valid_to)}`;
246 + if (r.valid_from) return `since ${fmtDate(r.valid_from)}`;
247 + if (r.valid_to) return `until ${fmtDate(r.valid_to)}`;
248 + return null;
249 +}
250 +
251 +function RelationshipRow({ r, showKind = false }: { r: Relationship; showKind?: boolean }) {
252 + const name = relationshipName(r);
253 + const v = validity(r);
254 + const ended = !!r.valid_to;
255 + return (
256 + <li className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 py-1.5 text-sm', ended && 'text-ink-2')}>
257 + <CompanyLogo name={name} candidates={r.company ? logoCandidates({ logo_url: r.company.logo_url ?? null }) : []} size={20} rounded="sm" />
258 + {r.company ? (
259 + <Link href={routes.company(r.company.slug)} className="min-w-0 truncate font-medium text-ink hover:text-accent">
260 + {name}
261 + </Link>
262 + ) : (
263 + <span className="min-w-0 truncate" title="Not an atlas company">
264 + {name}
265 + </span>
266 + )}
267 + {showKind && <Chip>{(r.kind ?? '').toLowerCase().replace(/_/g, ' ')}</Chip>}
268 + {ended && <Chip>former</Chip>}
269 + <span className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-ink-3">
270 + {v && <span className="tnum">{v}</span>}
271 + {typeof r.confidence === 'number' && (
272 + <span className="tnum" title="relationship confidence">
273 + {Math.round(r.confidence * 100)} %
274 + </span>
275 + )}
276 + <SourceChip source={r.provenance?.source} retrievedAt={null} />
277 + </span>
278 + </li>
279 + );
280 +}
281 +
282 +/** Parent / owners / subsidiaries / holdings / acquisitions, grouped; collapsible when there are more than 8 rows. */
283 +export function CorporateStructurePanel({ relationships, className, collapseAfter = 8 }: { relationships: Relationship[]; className?: string; collapseAfter?: number }) {
284 + const rels = relationships ?? [];
285 + const groups = groupRelationships(rels);
286 + const total = rels.length;
287 + const body = (
288 + <div className="space-y-4">
289 + {groups.map(({ group, rows }) => (
290 + <div key={group.id}>
291 + <p className="mb-0.5 flex items-baseline gap-2 text-[11px] font-semibold uppercase tracking-[0.1em] text-ink-3" title={group.hint}>
292 + {group.label} <span className="tnum font-normal normal-case tracking-normal">({rows.length})</span>
293 + </p>
294 + <ul className="divide-y divide-rule border-y border-rule">
295 + {rows.map((r, i) => (
296 + <RelationshipRow key={`${r.kind}:${r.company?.slug ?? r.to_name ?? i}:${r.valid_from ?? ''}`} r={r} showKind={group.id === 'other'} />
297 + ))}
298 + </ul>
299 + </div>
300 + ))}
301 + <Note>Structure as recorded by the cited sources (Wikidata statements, registries, first-party pages). “Former” marks a relationship with a recorded end date; the platform does not infer ownership changes.</Note>
302 + </div>
303 + );
304 + return (
305 + <section className={className} data-corporate-structure>
306 + <p className="eyebrow mb-2">Corporate structure</p>
307 + {total === 0 ? (
308 + <Empty compact title="No corporate relationships recorded yet." />
309 + ) : total > collapseAfter ? (
310 + <details className="group">
311 + <summary className="flex cursor-pointer list-none items-center gap-2 border-y border-rule py-2 text-sm text-ink-2 hover:text-ink [&::-webkit-details-marker]:hidden">
312 + <ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
313 + <span>
314 + {fmtInt(total)} recorded {plural(total, 'relationship')} · {groups.map((g) => `${g.rows.length} ${g.group.label.toLowerCase()}`).join(' · ')}
315 + </span>
316 + </summary>
317 + <div className="pt-3">{body}</div>
318 + </details>
319 + ) : (
320 + body
321 + )}
322 + </section>
323 + );
324 +}
325 +
326 +/* ---------------------------------------------------------------------------------------------- products (Wikidata) */
327 +/** Fallback for the Products tab when no catalogue surface has been reconciled: products/services as stated on Wikidata. */
328 +export function WikidataProducts({ products, source, className }: { products: string[]; source?: { url: string | null; retrieved_at: string } | null; className?: string }) {
329 + if (!products?.length) return null;
330 + return (
331 + <section className={className} data-wikidata-products>
332 + <p className="eyebrow mb-2 flex flex-wrap items-center gap-2">
333 + Products &amp; services listed on Wikidata <span className="tnum normal-case tracking-normal">({products.length})</span>
334 + <SourceChip source="wikidata" url={source?.url ?? null} retrievedAt={source?.retrieved_at ?? null} />
335 + </p>
336 + <ul className="flex flex-wrap gap-1.5">
337 + {products.map((p) => (
338 + <li key={p}>
339 + <Chip tone="neutral" className="font-normal">
340 + {p}
341 + </Chip>
342 + </li>
343 + ))}
344 + </ul>
345 + <Note className="mt-2">These names come from Wikidata “product or material produced” statements — not from a monitored catalogue page, so they carry no first-seen / last-seen dates.</Note>
346 + </section>
347 + );
348 +}
349 +
350 +export type { CompanyFact };
modified apps/web/src/lib/format.ts +17 −0
@@ -69,6 +69,23 @@ export function fmtUsd(v: Num | undefined | unknown, digits = 2): string {
69 69 const n = num(v);
70 70 return n === null ? DASH : `$${n.toFixed(digits)}`;
71 71 }
72 +/**
73 + * Compact currency for stated financial facts: 130.5B USD → "$130.5B", 34.2B EUR → "€34.2B", 48T JPY → "¥48.0T".
74 + * Never converts between currencies; unknown ISO codes fall back to "<compact> <CODE>".
75 + */
76 +export function fmtMoney(value: Num | undefined | unknown, currency: string | null | undefined): string {
77 + const n = num(value);
78 + if (n === null) return DASH;
79 + const code = (currency ?? '').toUpperCase();
80 + if (code) {
81 + try {
82 + return new Intl.NumberFormat('en-US', { style: 'currency', currency: code, notation: 'compact', maximumFractionDigits: 1, currencyDisplay: 'narrowSymbol' }).format(n);
83 + } catch {
84 + /* invalid code */
85 + }
86 + }
87 + return code ? `${nfCompact.format(n)} ${code}` : nfCompact.format(n);
88 +}
72 89 export function fmtPrice(price: Num | undefined, currency: string | null | undefined, text?: string | null): string {
73 90 const n = num(price);
74 91 if (n === null) return text ?? DASH;
added apps/web/src/lib/profile.ts +157 −0
@@ -0,0 +1,157 @@
1 +/**
2 + * Helpers for the enrichment profile (Wikidata / Wikipedia / homepage / registries): source labels, description
3 + * attribution, socials, corporate-structure grouping, leadership merge. Server- and client-safe (no React).
4 + */
5 +import type { CompanyCard, CompanyProfile, CompanySocials, Person, ProfileSource, Relationship } from './types';
6 +
7 +/** Human label for a provenance source id (`wikidata`, `sec_edgar`, `page`, …). Unknown ids are humanised, never hidden. */
8 +export const SOURCE_LABELS: Record<string, string> = {
9 + wikidata: 'Wikidata',
10 + wikipedia: 'Wikipedia',
11 + homepage: 'Website',
12 + website: 'Website',
13 + page: 'Leadership page',
14 + leadership_page: 'Leadership page',
15 + llm: 'LLM',
16 + sec: 'SEC EDGAR',
17 + sec_edgar: 'SEC EDGAR',
18 + edgar: 'SEC EDGAR',
19 + gleif: 'GLEIF',
20 + registry: 'Registry',
21 + opencorporates: 'OpenCorporates',
22 + crunchbase: 'Crunchbase',
23 + observed: 'Observed',
24 +};
25 +export function sourceLabel(source: string | null | undefined): string {
26 + if (!source) return 'Unknown source';
27 + const k = source.toLowerCase();
28 + if (SOURCE_LABELS[k]) return SOURCE_LABELS[k];
29 + const w = k.replace(/[_-]+/g, ' ').trim();
30 + return w.charAt(0).toUpperCase() + w.slice(1);
31 +}
32 +
33 +/** Description + attribution: profile first, then the registry description (unattributed → no attribution line). */
34 +export function descriptionOf(c: CompanyCard): { text: string; source: CompanyProfile['description_source'] | null; url: string | null; license: string | null } | null {
35 + const p = c.profile;
36 + if (p?.description) return { text: p.description, source: p.description_source, url: p.description_url, license: p.description_license };
37 + if (c.description) return { text: c.description, source: null, url: null, license: null };
38 + return null;
39 +}
40 +
41 +/** Best available logo candidates, in order (profile logo → profile icon → legacy `logo_url`). */
42 +export function logoCandidates(c: { logo_url?: string | null; profile?: CompanyProfile | null }): string[] {
43 + const out: string[] = [];
44 + for (const u of [c.profile?.logo_url, c.profile?.icon_url, c.logo_url]) if (u && !out.includes(u)) out.push(u);
45 + return out;
46 +}
47 +
48 +/** 1–2 letter monogram for the fallback plate ("Dassault Systèmes" → "DS", "Arm" → "A"). */
49 +export function monogram(name: string): string {
50 + const words = name
51 + .replace(/[^\p{L}\p{N} ]+/gu, ' ')
52 + .split(/\s+/)
53 + .filter(Boolean);
54 + if (!words.length) return '?';
55 + if (words.length === 1) return words[0]!.slice(0, 1).toUpperCase();
56 + return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase();
57 +}
58 +
59 +export const SOCIAL_LABELS: Record<keyof CompanySocials, string> = { linkedin: 'LinkedIn', x: 'X', youtube: 'YouTube', facebook: 'Facebook', instagram: 'Instagram', github: 'GitHub', tiktok: 'TikTok', crunchbase: 'Crunchbase' };
60 +const SOCIAL_ORDER: (keyof CompanySocials)[] = ['linkedin', 'x', 'youtube', 'github', 'instagram', 'facebook', 'tiktok', 'crunchbase'];
61 +export function socialsOf(p: CompanyProfile | null | undefined): { key: keyof CompanySocials; label: string; url: string }[] {
62 + if (!p?.socials) return [];
63 + return SOCIAL_ORDER.filter((k) => typeof p.socials[k] === 'string' && /^https?:\/\//i.test(p.socials[k] as string)).map((k) => ({ key: k, label: SOCIAL_LABELS[k], url: p.socials[k] as string }));
64 +}
65 +
66 +/** Per-field provenance lookup (`sources[]` is a flat list keyed by `field`). */
67 +export function sourceFor(p: CompanyProfile | null | undefined, ...fields: string[]): ProfileSource | null {
68 + if (!p?.sources?.length) return null;
69 + for (const f of fields) {
70 + const s = p.sources.find((x) => x.field === f);
71 + if (s) return s;
72 + }
73 + return null;
74 +}
75 +
76 +/** Wikidata-sourced people first by role weight (CEO → chair → founders → other executives), then page-observed, then name. */
77 +const ROLE_RANK: [RegExp, number][] = [
78 + [/\b(chief executive|ceo|managing director|président-directeur|directeur général)\b/i, 0],
79 + [/\b(executive chair|chair(man|woman|person)?|chair of the board|président du conseil)\b/i, 1],
80 + [/\b(co-?founder|founder|fondateur|fondatrice)\b/i, 2],
81 + [/\b(president|chief \w+ officer|c[a-z]o\b|general counsel)\b/i, 3],
82 + [/\b(vp|vice president|head of|director)\b/i, 4],
83 +];
84 +export function roleRank(title: string | null | undefined): number {
85 + if (!title) return 6;
86 + for (const [re, r] of ROLE_RANK) if (re.test(title)) return r;
87 + return 5;
88 +}
89 +
90 +export type MergedPerson = Person & { sources: ('wikidata' | 'page')[] };
91 +/** Merge page-observed and Wikidata-sourced people by normalised name; page metadata wins, both source chips are kept. */
92 +export function mergePeople(list: Person[]): MergedPerson[] {
93 + const byKey = new Map<string, MergedPerson>();
94 + for (const p of list) {
95 + const src = p.source === 'wikidata' ? 'wikidata' : 'page';
96 + const key = p.name
97 + .normalize('NFD')
98 + .replace(/[\u0300-\u036f]/g, '')
99 + .toLowerCase()
100 + .replace(/[^a-z0-9]+/g, ' ')
101 + .trim();
102 + const cur = byKey.get(key);
103 + if (!cur) {
104 + byKey.set(key, { ...p, sources: [src] });
105 + continue;
106 + }
107 + if (!cur.sources.includes(src)) cur.sources.push(src);
108 + // prefer the page row's identity/title (observed first-party), keep the richer title otherwise
109 + if (src === 'page' && cur.source === 'wikidata') Object.assign(cur, { ...p, sources: cur.sources });
110 + else if (!cur.title && p.title) cur.title = p.title;
111 + cur.is_executive = cur.is_executive || p.is_executive;
112 + }
113 + return [...byKey.values()].sort((a, b) => {
114 + const ra = roleRank(a.title);
115 + const rb = roleRank(b.title);
116 + if (ra !== rb) return ra - rb;
117 + if (a.is_executive !== b.is_executive) return a.is_executive ? -1 : 1;
118 + return a.name.localeCompare(b.name);
119 + });
120 +}
121 +
122 +/** Corporate-structure groups, in display order. `PARENT_OF X` means X is a subsidiary of this company. */
123 +export const STRUCTURE_GROUPS: { id: string; kinds: string[]; label: string; hint: string }[] = [
124 + { id: 'parent', kinds: ['SUBSIDIARY_OF', 'PARENT'], label: 'Parent', hint: 'This company is recorded as a subsidiary of' },
125 + { id: 'owners', kinds: ['OWNED_BY'], label: 'Owners', hint: 'Recorded owners / significant shareholders' },
126 + { id: 'acquired_by', kinds: ['ACQUIRED_BY'], label: 'Acquired by', hint: 'Recorded acquirer' },
127 + { id: 'subsidiaries', kinds: ['PARENT_OF', 'SUBSIDIARY'], label: 'Subsidiaries', hint: 'Companies recorded as subsidiaries' },
128 + { id: 'owns', kinds: ['OWNER_OF'], label: 'Owns', hint: 'Recorded holdings' },
129 + { id: 'acquisitions', kinds: ['ACQUIRED', 'ACQUIRER_OF'], label: 'Acquisitions', hint: 'Companies recorded as acquired' },
130 +];
131 +export function groupRelationships(rels: Relationship[]): { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] {
132 + const used = new Set<Relationship>();
133 + const out: { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] = [];
134 + for (const g of STRUCTURE_GROUPS) {
135 + const rows = rels.filter((r) => g.kinds.includes((r.kind ?? '').toUpperCase()));
136 + if (rows.length) {
137 + rows.forEach((r) => used.add(r));
138 + out.push({ group: g, rows: sortRel(rows) });
139 + }
140 + }
141 + const other = rels.filter((r) => !used.has(r));
142 + if (other.length) out.push({ group: { id: 'other', label: 'Other relationships', hint: 'Competitors, partners and other recorded links', kinds: [] }, rows: sortRel(other) });
143 + return out;
144 +}
145 +function sortRel(rows: Relationship[]): Relationship[] {
146 + // current (no valid_to) first, then most recent, then confidence
147 + return [...rows].sort((a, b) => {
148 + const ea = a.valid_to ? 1 : 0;
149 + const eb = b.valid_to ? 1 : 0;
150 + if (ea !== eb) return ea - eb;
151 + if ((a.valid_from ?? '') !== (b.valid_from ?? '')) return (b.valid_from ?? '').localeCompare(a.valid_from ?? '');
152 + return b.confidence - a.confidence;
153 + });
154 +}
155 +export function relationshipName(r: Relationship): string {
156 + return r.company?.display_name ?? r.to_name ?? 'Unnamed counterpart';
157 +}
modified apps/web/src/lib/types.ts +89 −2
@@ -50,6 +50,82 @@ export interface CompanyCounts {
50 50 jobs_open: number;
51 51 }
52 52
53 +/** Where the one-paragraph description comes from — drives the attribution line under it (spec: never unattributed). */
54 +export type DescriptionSource = 'wikipedia' | 'wikidata' | 'homepage' | 'llm';
55 +
56 +/** A monetary fact as stated by its source for one fiscal year — rendered compact with the year, never converted. */
57 +export interface MoneyFact {
58 + value: number;
59 + currency: string;
60 + year: number;
61 +}
62 +
63 +/** Per-field provenance for the enrichment profile (`field` matches a `CompanyProfile` key, e.g. `revenue`, `hq`). */
64 +export interface ProfileSource {
65 + field: string;
66 + source: string;
67 + url: string | null;
68 + retrieved_at: string;
69 +}
70 +
71 +export interface CompanySocials {
72 + linkedin?: string;
73 + x?: string;
74 + youtube?: string;
75 + facebook?: string;
76 + instagram?: string;
77 + github?: string;
78 + tiktok?: string;
79 + crunchbase?: string;
80 +}
81 +
82 +/**
83 + * Enrichment profile (Wikidata / Wikipedia / homepage / registries). Every field may be null: the UI hides missing facts
84 + * rather than rendering dashes, and every rendered fact carries its source chip.
85 + */
86 +export interface CompanyProfile {
87 + description: string | null;
88 + description_source: DescriptionSource | null;
89 + description_url: string | null;
90 + description_license: string | null;
91 + logo_url: string | null;
92 + icon_url: string | null;
93 + founded_year: number | null;
94 + legal_form: string | null;
95 + employees: number | null;
96 + employees_year: number | null;
97 + revenue: MoneyFact | null;
98 + net_income: MoneyFact | null;
99 + total_assets: MoneyFact | null;
100 + hq: { city: string | null; region: string | null; country: string | null; address: string | null; lat: number | null; lon: number | null };
101 + ticker: string | null;
102 + exchange: string | null;
103 + isin: string | null;
104 + lei: string | null;
105 + sec_cik: string | null;
106 + public_company: boolean;
107 + wikipedia_url: string | null;
108 + wikidata_url: string | null;
109 + official_website: string | null;
110 + phone: string | null;
111 + products: string[];
112 + industries: string[];
113 + industry_labels: string[];
114 + socials: CompanySocials;
115 + enriched_at: string | null;
116 + sources: ProfileSource[];
117 +}
118 +
119 +/** Flattened, already-formatted fact with provenance (`/companies/{slug}` → `facts`). */
120 +export interface CompanyFact {
121 + key: string;
122 + label: string;
123 + value: string;
124 + source: string;
125 + url: string | null;
126 + retrieved_at: string;
127 +}
128 +
53 129 export interface CompanyCard {
54 130 id: string;
55 131 slug: string;
@@ -78,6 +154,8 @@ export interface CompanyCard {
78 154 last_event_at: string | null;
79 155 last_observed_at: string | null;
80 156 sparkline?: number[];
157 + /** Enrichment profile; absent on API builds that predate it and null-filled while enrichment is pending. */
158 + profile?: CompanyProfile | null;
81 159 }
82 160
83 161 export interface MetricDetail {
@@ -89,19 +167,26 @@ export interface MetricDetail {
89 167 formula_version?: string;
90 168 }
91 169
170 +/** Corporate-structure kinds the UI groups explicitly; anything else lands under "Other relationships". */
171 +export type RelationshipKind = 'PARENT_OF' | 'SUBSIDIARY_OF' | 'OWNED_BY' | 'OWNER_OF' | 'ACQUIRED_BY' | 'ACQUIRED' | 'COMPETITOR' | 'PARTNER' | (string & {});
172 +
92 173 export interface Relationship {
93 − kind: string;
94 − company: { slug: string; display_name: string } | null;
174 + kind: RelationshipKind;
175 + /** Counterpart when it is an atlas company (links to its profile); else only `to_name` is known. */
176 + company: { slug: string; display_name: string; logo_url?: string | null } | null;
95 177 to_name: string | null;
96 178 valid_from: string | null;
97 179 valid_to: string | null;
98 180 confidence: number;
181 + provenance: { source: string; property?: string };
99 182 }
100 183
101 184 export interface CompanyDetail extends CompanyCard {
102 185 aliases: string[];
103 186 domains: { domain: string; kind: string }[];
104 187 relationships: Relationship[];
188 + /** Sourced key facts (flattened); complements `profile` — the UI merges both and hides nothing that is sourced. */
189 + facts: CompanyFact[];
105 190 metrics_detail: MetricDetail[];
106 191 sensors_by_surface: Record<string, number>;
107 192 coverage: { historical_coverage: number | null; first_observed_at: string | null; days_observed: number; sensor_uptime: number | null };
@@ -316,6 +401,8 @@ export interface Person {
316 401 removed_at: string | null;
317 402 status: string;
318 403 source_url: string | null;
404 + /** `page` = observed on the monitored leadership page (default when absent); `wikidata` = position statement on Wikidata. */
405 + source?: 'wikidata' | 'page';
319 406 }
320 407
321 408 export interface Product {
modified docs/FRONTEND.md +62 −5
@@ -26,7 +26,7 @@ node apps/web/qa/flows.mjs [BASE] # live feed → drawer → company
26 26 | `/` | ISR 60 s + client live counters/feed | `/pulse`, `/live`, `/index`, `/signals`, `/stats` |
27 27 | `/live` (`?event_type&min_importance&min_confidence&country&industry`) | dynamic + SSE `/live/stream` (polling fallback + 45 s watchdog) | `/live`, `/countries`, `/industries` |
28 28 | `/companies` (filters, sort, search, pagination) | ISR 120 s | `/companies?sparkline=1` |
29 −| `/company/[slug]` (`?tab=overview|timeline|signals|jobs|products|pricing|locations|leadership|sources|history`, `&filter=`, `&status=&ai=1`) | ISR 120 s; only the active tab's data is fetched | `/companies/{slug}` + per-tab endpoints |
29 +| `/company/[slug]` (`?tab=overview|timeline|signals|jobs|products|pricing|locations|leadership|sources|history`, `&filter=`, `&status=&ai=1`) | ISR 120 s; only the active tab's data is fetched | `/companies/{slug}` (card + `profile` + `facts` + `relationships`) + per-tab endpoints |
30 30 | `/company/compare?companies=a,b,c` | ISR 120 s + client picker (`/search/suggest`) | `/companies/compare` |
31 31 | `/events`, `/events/[id]` | ISR 60/120 s | `/events`, `/events/summary`, `/events/{id}` (+ `/companies/{slug}/events`) |
32 32 | `/change/[id]`, `/sensor/[id]`, `/snapshot/[id]`, `/snapshot/[id]/diff/[other]` | ISR (noindex) | provenance endpoints |
@@ -45,13 +45,15 @@ re-applies the theme. No root `loading.tsx` (Next 16 soft-404 gotcha).
45 45
46 46 ```
47 47 src/lib types.ts (docs/API.md mirror) · api.ts (server fetch, ApiError, safe/orNull) · client-api.ts (same-origin, owner/admin)
48 − format.ts · site.ts (names, nav, routes) · event-styles.ts (type → hue/label/subtype wording) · countries.ts (ISO)
49 − owner.ts (X-CA-Owner-Token + local watched mirror) · admin.ts / admin-modules.ts · params.ts · prepaint.ts · fonts.ts
48 + format.ts (incl. fmtMoney compact currency) · site.ts (names, nav, routes) · event-styles.ts (type → hue/label/subtype wording)
49 + countries.ts (ISO) · profile.ts (enrichment profile helpers: source labels, description attribution, logo candidates,
50 + monogram, socials, people merge/ranking, corporate-structure grouping) · owner.ts · admin.ts / admin-modules.ts · params.ts · prepaint.ts · fonts.ts
50 51 src/components layout/ (header, mobile tab bar, ⌘K dialog, theme, density, footer) · ui/ (section/stat/empty, badges, tabs, sheet,
51 52 pagination, key-value, live dot/ago, skeleton) · charts/ (Sparkline, LineChart, Bars/Columns, Heatmap, WorldMap)
52 53 events/ (row/list, drawer + context, evidence, filters, diff viewer) · live/ (SSE feed, counters)
53 − company/ (header + density strip, metric tiles, table/cards/mini list, tab panels, compare picker, watch button,
54 − watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (mark, logo)
54 + company/ (header + density strip, company-logo (client, error → monogram), profile-panels (SourceChip, DescriptionAttribution,
55 + KeyFactsPanel, CorporateStructurePanel, WikidataProducts), metric tiles, table/cards/mini list, tab panels, compare picker,
56 + watch button, watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (mark, logo)
55 57 qa/ mock-api.mjs · screens.mjs · flows.mjs · screens/ (output, git-ignored)
56 58 ```
57 59
@@ -68,6 +70,61 @@ sources with detection times and a link to `/change/[id]`; retracted events stay
68 70 from counts. Missing metrics render as a dash with "not enough monitored evidence" — never 0. Empty states: "No monitored
69 71 evidence available yet." / "Last successfully checked …".
70 72
73 +## Company pages — enrichment profile (2026-09-13)
74 +
75 +Built against the additive API 1.1 shape (`CompanyCard.profile`, `CompanyDetail.facts`, `relationships[].provenance` +
76 +`company.logo_url`, `people[].source`); every field may be null and the UI hides what is missing instead of rendering dashes.
77 +
78 +- **Header** (`company-header.tsx`): logo via `CompanyLogo` (candidates `profile.logo_url → profile.icon_url → logo_url`, plain lazy
79 + `<img referrerPolicy="no-referrer">`, `onError` walks to the next candidate, then a monogram plate — `data-logo="image|monogram"`);
80 + name · legal name · legal form; domain chip + listing chip (`ticker · exchange`, only when `public_company`); ISO country + HQ city/
81 + region; `est. <founded_year>`; employees = sourced count with year when known, else the stated band; industries; attributed
82 + description (`DescriptionAttribution`: Wikipedia → "Source: Wikipedia · <license, default CC BY-SA 4.0>" link; Wikidata → "Source:
83 + Wikidata · CC0"; homepage → "From the company's website"; llm → warning chip "Generated from the company's public pages" + one-line
84 + caveat; unattributed registry description → no line); socials row (text chips with ↗, `rel="noopener nofollow noreferrer"`;
85 + lucide 1.x has no brand icons, so no fake glyphs). `generateMetadata` uses the profile description (truncated at 220 chars).
86 +- **Key facts** (`KeyFactsPanel`, first block of the Overview main column, `data-key-facts`): founded · headquarters (city, region,
87 + country name + "map" link to `?tab=locations`) · employees (year) · revenue / net income / total assets (`fmtMoney` compact in the
88 + stated currency + `FY<year>`, never converted) · legal form · listing · ISIN · LEI (→ GLEIF record) · SEC CIK (→ EDGAR browse) ·
89 + Industry (Wikidata) · phone · references (Wikipedia / Wikidata). Each row ends with a `SourceChip` (label from `SOURCE_LABELS`,
90 + `title` = "<source> · retrieved <retrieved_at>", links to the source URL when given); provenance comes from `profile.sources[]`
91 + by field with `facts[]` as fallback, and any `facts[]` key not already rendered is appended as a generic row. Empty →
92 + "No sourced facts yet — enrichment pending." Footer note: figures quoted as retrieved.
93 +- **Corporate structure** (`CorporateStructurePanel`, Overview aside, replaces the flat Relationships list): grouped Parent
94 + (`SUBSIDIARY_OF`) · Owners (`OWNED_BY`) · Acquired by · Subsidiaries (`PARENT_OF`) · Owns (`OWNER_OF`) · Acquisitions (`ACQUIRED`) ·
95 + Other relationships (kind chip); each row = 20 px logo/monogram, link to the atlas company or plain `to_name`, "former" chip when
96 + `valid_to`, validity (`since … / … – …`), confidence %, provenance chip. More than 8 rows → collapsed `<details>` whose summary
97 + counts the groups. Current relationships sort before ended ones.
98 +- **Leadership** (`PeoplePanel`): `mergePeople` joins page-observed and Wikidata rows by normalised name (both chips kept, page
99 + title wins), ordered CEO → chair → founders → other C-level → VP → rest (`roleRank`). Chips "Leadership page" / "Wikidata";
100 + Wikidata-only rows read "recorded on Wikidata · retrieved <date> · statement ↗". The "No longer listed" group stays separate:
101 + page rows "no longer listed since …", Wikidata rows "no longer recorded as current on Wikidata · <date>". Heading counts both
102 + sources. Rows carry `data-person-source="page|wikidata|page+wikidata"`.
103 +- **Products**: when the observed catalogue is empty and `profile.products` is not, the tab shows "Products & services listed on
104 + Wikidata" chips (`WikidataProducts`, provenance = `sources[products]` else the Wikidata entity) with a note that they carry no
105 + first-seen / last-seen dates, above the usual empty state.
106 +- **Directory / lists**: `CompanyTable` desktop rows show a 28 px logo and `domain · <one-line description>` (truncated, full text in
107 + `title`; `showDescription={false}` to opt out); mobile cards and `CompanyMiniList` show 32 / 24 px logos; compare column headers
108 + 20 px. Rankings, industry, country, search and watchlist inherit through `CompanyTable`.
109 +- **Mock** (`qa/mock-api.mjs`): hand-written profiles for 20 companies (complete: nvidia, apple, toyota, sony, roche, sap, spotify,
110 + adyen, arm, tsmc, siemens, samsung-electronics; partial: stripe, shopify, cohere (llm description, icon only), anthropic (wikidata
111 + description), mistral-ai, nubank, revolut, klarna), null-filled profiles (cabify, careem, flutterwave, zerodha, discovery), no
112 + `profile` key at all (compass, bitso, wiz), auto-derived minimal profiles for the rest; `facts[]`, v1.1 relationships
113 + (Sony has 13 → collapsed; Arm has parent + owner; SAP/Sony have former relationships), Wikidata executives (one per company also
114 + on the page → merge), `anthropic` / `mistral-ai` without an observed catalogue. Sample logos are served by the mock itself
115 + (`/api/v1/_mock/logo|icon/<slug>.svg`) so QA never hits the network. Figures are approximate public values for layout only.
116 +
117 +## QA results (2026-09-13, company pages upgrade, mock API)
118 +
119 +`qa/screens.mjs`: 206 checks (50 routes × 390/1440 × dark/light — new routes `/company/nvidia`, `/company/sony`,
120 +`/company/sony?tab=leadership`, `/company/cohere`, `/company/cabify`, `/company/anthropic?tab=products`, `/company/compass` — plus
121 +counters + SSE): all OK, zero console errors, zero 404/5xx sub-requests, zero horizontal overflow, zero sub-40 px controls on
122 +mobile. `qa/flows.mjs`: 30/30 (15 steps × 390/1440). Element screenshots of the header (complete / Wikidata-described /
123 +LLM-described / no-profile), key facts (full and empty), corporate structure (grouped, parent+owner, 13-row collapsed → open),
124 +merged leadership, Wikidata products fallback, directory rows and compare headers reviewed by eye in both themes; the logo
125 +fallback was exercised by breaking every `<img>` src at runtime (images fall through to the icon candidate, then to monograms).
126 +`pnpm -r typecheck` clean; `pnpm build` green.
127 +
71 128 ## QA results (2026-09-12, mock API)
72 129
73 130 `qa/screens.mjs`: 178 checks (44 routes × 390/1440 × dark/light + counters + SSE) — all 200/404 as expected, zero console
74 131