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:<slug></span> or <span className="mono">family:<name></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