SPB Git

spb/ai-atlas

Public
10.9 KB · 194 lines tsx
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ChangeRow } from '@/components/changes/change-row';4import { EntityRow } from '@/components/ui/entity';5import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';6import { EmptyState, Unavailable } from '@/components/ui/unavailable';7import { api, ApiError, safe } from '@/lib/api';8import { fmtDate, fmtInt, num } from '@/lib/format';9import { routes } from '@/lib/site';10import type { ChangeEvent, DiffPayload, EntitySummary } from '@/lib/types';1112type SP = { a?: string; b?: string; scope?: string; scope_custom?: string };13const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;1415function todayUtc(): string {16  return new Date().toISOString().slice(0, 10);17}18function daysBefore(iso: string, days: number): string {19  const d = new Date(`${iso}T00:00:00Z`);20  d.setUTCDate(d.getUTCDate() - days);21  return d.toISOString().slice(0, 10);22}23function resolve(sp: SP): { a: string; b: string; scope: string; valid: boolean } {24  const b = sp.b && ISO_DAY.test(sp.b) ? sp.b : todayUtc();25  const a = sp.a && ISO_DAY.test(sp.a) ? sp.a : daysBefore(b, 7);26  const scope = (sp.scope_custom?.trim() || sp.scope || 'all').trim() || 'all';27  const valid = (!sp.a || ISO_DAY.test(sp.a)) && (!sp.b || ISO_DAY.test(sp.b)) && a < b;28  return { a, b, scope, valid };29}3031export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {32  const { a, b, scope } = resolve(await searchParams);33  const title = `What changed between ${fmtDate(a)} and ${fmtDate(b)}${scope !== 'all' ? ` (${scope})` : ''}`;34  return { title, description: `New, gone and changed entities, prices and benchmark results in the AI ecosystem between ${a} and ${b}, straight from the AI Atlas change log.`, alternates: { canonical: routes.diff({ a, b, scope }) }, robots: { index: false } };35}3637function describeScope(s: DiffPayload['scope'], fallback: string): string {38  if (!s) return fallback === 'all' ? 'the whole atlas' : fallback;39  if (typeof s === 'string') return s;40  const kind = typeof s.kind === 'string' ? s.kind : fallback;41  const parts = Object.entries(s)42    .filter(([k, v]) => k !== 'kind' && v !== null && v !== undefined && v !== '')43    .map(([k, v]) => `${k} ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);44  if (kind === 'all') return 'the whole atlas';45  return parts.length ? `${kind} · ${parts.join(' · ')}` : kind;46}4748function Capped({ shown, total, what }: { shown: number; total: number | null; what: string }) {49  if (total === null || total <= shown) return null;50  return <Note className="mt-2">Showing the first {fmtInt(shown)} of {fmtInt(total)} {what}. Narrow the scope or the dates to see everything.</Note>;51}5253function EventsSection({ id, title, items, total, empty }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string }) {54  return (55    <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}>56      {items.length === 0 ? (57        <EmptyState title={empty} />58      ) : (59        <>60          <ul className="border-t border-rule">61            {items.map((e) => (62              <ChangeRow key={e.id} e={e} showDate live={false} />63            ))}64          </ul>65          <Capped shown={items.length} total={total} what="events" />66        </>67      )}68    </Section>69  );70}7172function EntitiesSection({ id, title, items, total, empty }: { id: string; title: string; items: EntitySummary[]; total: number | null; empty: string }) {73  return (74    <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}>75      {items.length === 0 ? (76        <EmptyState title={empty} />77      ) : (78        <>79          <ul className="border-t border-rule">80            {items.map((e) => (81              <EntityRow key={e.id} e={e} showType showDescription={false} />82            ))}83          </ul>84          <Capped shown={items.length} total={total} what="entities" />85        </>86      )}87    </Section>88  );89}9091export default async function DiffPage({ searchParams }: { searchParams: Promise<SP> }) {92  const sp = await searchParams;93  const { a, b, scope, valid } = resolve(sp);94  const orgs = await safe(api.companies({ limit: 30, sort: 'models' }));95  let payload: DiffPayload | null = null;96  let error: string | null = null;97  if (!valid) error = 'Dates must be YYYY-MM-DD and the first date must come before the second.';98  else {99    try {100      payload = await api.diff(a, b, scope);101    } catch (e) {102      error = e instanceof ApiError && e.status === 400 ? e.detail ?? 'The API rejected these parameters.' : null;103    }104  }105  const cls = 'h-11 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none';106  const orgOptions = (orgs?.items ?? []).map((o) => ({ value: `org:${o.slug}`, label: `${o.name} (${fmtInt(o.model_count)} models)` }));107  const knownScopes = new Set(['all', 'models', ...orgOptions.map((o) => o.value)]);108  const custom = knownScopes.has(scope) ? '' : scope;109  const c = payload?.counts ?? {};110  const cnt = (k: string): number | null => num(c[k]);111112  return (113    <Container>114      <PageHeader eyebrow="Diff" title={<>What changed between <span className="tnum">{fmtDate(a)}</span> and <span className="tnum">{fmtDate(b)}</span></>} lede="Two dates, one scope: the entities that appeared or disappeared, and every property, price and benchmark change recorded in between. Built from the change log — nothing is inferred." aside={payload ? <p className="text-sm text-ink-3">Scope: <span className="text-ink-2">{describeScope(payload.scope, scope)}</span></p> : undefined}>115        <form action="/diff" method="get" className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">116          <label className="block min-w-0">117            <span className="eyebrow block pb-1">From (UTC)</span>118            <input type="date" name="a" defaultValue={a} max={todayUtc()} className={cls} required />119          </label>120          <label className="block min-w-0">121            <span className="eyebrow block pb-1">To (UTC)</span>122            <input type="date" name="b" defaultValue={b} max={todayUtc()} className={cls} required />123          </label>124          <label className="block min-w-0">125            <span className="eyebrow block pb-1">Scope</span>126            <select name="scope" defaultValue={custom ? '' : scope} className={cls}>127              <option value="all">Everything</option>128              <option value="models">Models only</option>129              {orgOptions.length > 0 && (130                <optgroup label="Organization">131                  {orgOptions.map((o) => (132                    <option key={o.value} value={o.value}>133                      {o.label}134                    </option>135                  ))}136                </optgroup>137              )}138              {custom && <option value="">Custom (below)</option>}139            </select>140          </label>141          <label className="block min-w-0">142            <span className="eyebrow block pb-1">Custom scope</span>143            <input name="scope_custom" defaultValue={custom} placeholder="family:Claude · org:<slug>" className={cls} />144          </label>145          <div className="flex items-end gap-2">146            <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90">147              Compare dates148            </button>149            <Link href={routes.diff()} className="inline-flex h-11 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink">150              Reset151            </Link>152          </div>153        </form>154        <Note className="mt-2">155          Custom scope accepts <span className="mono">org:&lt;slug&gt;</span> or <span className="mono">family:&lt;name&gt;</span>; when filled it overrides the select. Quick links: <Link href={routes.diff({ a: daysBefore(todayUtc(), 1), b: todayUtc() })} className="link">yesterday → today</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 7), b: todayUtc(), scope: 'models' })} className="link">last 7 days, models</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 30), b: todayUtc() })} className="link">last 30 days</Link>156        </Note>157      </PageHeader>158159      <div className="pb-16">160        {error ? (161          <Unavailable what="Diff" reason={error} />162        ) : !payload ? (163          <Unavailable what="Diff" reason="The change log could not be read right now." />164        ) : (165          <>166            <StatGrid cols={8}>167              <Stat label="New entities" value={fmtInt(cnt('new_entities') ?? payload.new_entities.length)} href="#new" />168              <Stat label="Gone" value={fmtInt(cnt('gone_entities') ?? payload.gone_entities.length)} href="#gone" />169              <Stat label="Events" value={fmtInt(cnt('events'))} hint="all types" />170              <Stat label="Price changes" value={fmtInt(cnt('price_changes') ?? payload.price_changes.length)} href="#prices" accent />171              <Stat label="Benchmark changes" value={fmtInt(cnt('benchmark_changes') ?? payload.benchmark_changes.length)} href="#benchmarks" />172              <Stat label="Claims superseded" value={fmtInt(cnt('claims_superseded'))} />173              <Stat label={`Entities at ${fmtDate(a)}`} value={fmtInt(cnt('entities_at_a'))} />174              <Stat label={`Entities at ${fmtDate(b)}`} value={fmtInt(cnt('entities_at_b'))} />175            </StatGrid>176            <Note className="mt-2">177              Price rows opened {fmtInt(cnt('price_rows_opened'))} · closed {fmtInt(cnt('price_rows_closed'))}. Counts are totals over the period; the lists below are capped by the API.178            </Note>179180            <EntitiesSection id="new" title="New entities" items={payload.new_entities} total={cnt('new_entities')} empty="No new entities between these dates" />181            <EntitiesSection id="gone" title="Gone entities" items={payload.gone_entities} total={cnt('gone_entities')} empty="No entities disappeared between these dates" />182            <EventsSection id="properties" title="Property changes" items={payload.property_changes} total={cnt('property_changes')} empty="No property changes between these dates" />183            <EventsSection id="prices" title="Price changes" items={payload.price_changes} total={cnt('price_changes')} empty="No price changes between these dates" />184            <EventsSection id="benchmarks" title="Benchmark changes" items={payload.benchmark_changes} total={cnt('benchmark_changes')} empty="No benchmark changes between these dates" />185            <Note className="mt-6">186              “Gone” means an entity present at the first date is no longer current at the second (merged or retired) — its record and history are kept. Per-entity history: open any entity's History tab. <Link href="/methodology" className="link">Methodology →</Link>187            </Note>188          </>189        )}190      </div>191    </Container>192  );193}194
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme