SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
7.9 KB · 157 lines tsx
Raw Blame History
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