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>
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 & 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 | |