spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { LineChart } from '@/components/charts/line-chart';4import { Sparkline } from '@/components/charts/sparkline';5import { CompanyLogo } from '@/components/company/company-logo';6import { ComparePicker } from '@/components/company/compare-picker';7import { CountryChip, EventTypeBadge } from '@/components/ui/badges';8import { Container, Empty, Note, PageHeader, Section } from '@/components/ui/section';9import { api, safe } from '@/lib/api';10import { cn } from '@/lib/cn';11import { EVENT_TYPES } from '@/lib/event-styles';12import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';13import { str, type SP } from '@/lib/params';14import { logoCandidates } from '@/lib/profile';15import { METRIC_LABELS, routes } from '@/lib/site';1617export 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.' };18export const revalidate = 120;1920const METRICS = ['activity_score', 'hiring_momentum_30d', 'product_velocity', 'ai_adoption', 'geo_expansion', 'developer_momentum', 'corporate_change_index', 'open_jobs'] as const;2122export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {23 const sp = await searchParams;24 const slugs = (str(sp.companies) ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6);25 const data = slugs.length >= 2 ? await safe(api.compare(slugs)) : null;26 const names: Record<string, string> = {};27 if (data) for (const c of data.companies) names[c.slug] = c.display_name;28 else if (slugs.length === 1) {29 const c = await safe(api.company(slugs[0] as string));30 if (c) names[c.slug] = c.display_name;31 }32 const cols = data?.companies ?? [];33 return (34 <Container wide>35 <PageHeader eyebrow="Comparison" title="Compare companies" lede="Pick two to six companies. Metrics are the latest computed values; series show 90 days of Activity Score; events count structured events detected in the last 30 days." />36 <ComparePicker slugs={slugs} names={names} />37 {slugs.length < 2 ? (38 <Empty className="mt-6" title={slugs.length === 1 ? 'Add at least one more company to compare.' : 'Add two or more companies to start.'}>39 Try <Link href={routes.compare(['stripe', 'adyen', 'block'])} className="link">Stripe · Adyen · Block</Link>.40 </Empty>41 ) : !data ? (42 <Empty className="mt-6" title="Comparison unavailable." />43 ) : (44 <>45 <Section eyebrow="Metrics" title="Latest computed values">46 <div className="table-scroll">47 <table className="data-table">48 <thead>49 <tr>50 <th>Metric</th>51 {cols.map((c) => (52 <th key={c.slug} className="num">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" />55 {c.display_name}56 </Link>57 <span className="block font-normal normal-case tracking-normal text-ink-3">58 <CountryChip code={c.country} link={false} /> {c.industry_primary?.replace(/-/g, ' ')}59 </span>60 </th>61 ))}62 </tr>63 </thead>64 <tbody>65 {METRICS.map((m) => {66 const vals = cols.map((c) => num(data.metrics[m]?.[c.slug] ?? c.metrics[m]));67 const best = m === 'hiring_momentum_30d' || m === 'open_jobs' ? Math.max(...vals.map((v) => v ?? -Infinity)) : Math.max(...vals.map((v) => v ?? -Infinity));68 return (69 <tr key={m}>70 <td className="primary">{METRIC_LABELS[m]}</td>71 {vals.map((v, i) => (72 <td key={cols[i]!.slug} className={cn('num tnum', v !== null && v === best && cols.length > 1 && 'font-semibold text-accent', m === 'hiring_momentum_30d' && v !== null && (v > 0 ? 'text-positive' : v < 0 ? 'text-danger' : ''))}>73 {v === null ? '—' : m === 'hiring_momentum_30d' ? fmtPctSigned(v) : m === 'open_jobs' ? fmtInt(v) : fmtScore(v)}74 </td>75 ))}76 </tr>77 );78 })}79 <tr>80 <td className="primary">Sensors · events</td>81 {cols.map((c) => (82 <td key={c.slug} className="num tnum">83 {fmtInt(c.counts.sensors)} · {fmtInt(c.counts.events)}84 </td>85 ))}86 </tr>87 <tr>88 <td className="primary">Jobs (open · AI · new 30 d)</td>89 {cols.map((c) => {90 const j = data.jobs[c.slug];91 return (92 <td key={c.slug} className="num tnum">93 {j ? `${fmtInt(j.open)} · ${fmtInt(j.ai_open)} · ${fmtInt(j.new_30d)}` : '—'}94 </td>95 );96 })}97 </tr>98 <tr>99 <td className="primary">Listed locations</td>100 {cols.map((c) => (101 <td key={c.slug} className="num tnum">102 {fmtInt(data.locations[c.slug])}103 </td>104 ))}105 </tr>106 <tr>107 <td className="primary">Activity 30 d</td>108 {cols.map((c) => (109 <td key={c.slug} className="num">110 <Sparkline values={c.sparkline} width={90} height={22} tone="accent" className="ml-auto" />111 </td>112 ))}113 </tr>114 </tbody>115 </table>116 </div>117 <Note className="mt-2">Highlighted = highest value in the row. A dash means the metric has no inputs yet for that company — it is omitted, not zero.</Note>118 </Section>119 <Section eyebrow="Small multiples" title="Activity Score, 90 days">120 <LineChart series={cols.filter((c) => data.series[c.slug]?.length).map((c) => ({ id: c.slug, label: c.display_name, points: (data.series[c.slug] ?? []).map((p) => ({ day: p.day, value: p.value })) }))} height={220} yZero />121 </Section>122 <Section eyebrow="Events by type" title="Structured events, last 30 days">123 <div className="table-scroll">124 <table className="data-table compact">125 <thead>126 <tr>127 <th>Type</th>128 {cols.map((c) => (129 <th key={c.slug} className="num">130 {c.display_name}131 </th>132 ))}133 </tr>134 </thead>135 <tbody>136 {EVENT_TYPES.filter((t) => cols.some((c) => (data.events_30d[c.slug]?.[t] ?? 0) > 0)).map((t) => (137 <tr key={t}>138 <td>139 <EventTypeBadge type={t} />140 </td>141 {cols.map((c) => (142 <td key={c.slug} className="num tnum">143 {data.events_30d[c.slug]?.[t] ? <Link href={`${routes.company(c.slug, 'timeline')}`} className="row-link">{fmtInt(data.events_30d[c.slug]?.[t])}</Link> : <span className="text-ink-3">·</span>}144 </td>145 ))}146 </tr>147 ))}148 </tbody>149 </table>150 </div>151 </Section>152 </>153 )}154 </Container>155 );156}157