SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
9.6 KB · 149 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ChangeRow, groupByDay } from '@/components/changes/change-row';4import { LoadMore } from '@/components/changes/load-more';5import { RailFilters } from '@/components/changes/rail-filters';6import { TerminalLayout } from '@/components/layout/terminal';7import { ActiveFilters } from '@/components/listing/filters';8import { withParams } from '@/components/ui/pagination';9import { Container, Note, PageHeader } from '@/components/ui/section';10import { EmptyState, Unavailable } from '@/components/ui/unavailable';11import { api, apiD3, safe } from '@/lib/api';12import { fmtDate, fmtInt, num } from '@/lib/format';13import { categoryLabel, eventLabel, IMPORTANCE_LABELS, routes, typeLabel } from '@/lib/site';1415export const metadata: Metadata = {16  title: 'Changes — what changed in AI, as it happens',17  description: 'A live, source-attributed feed of changes in the AI ecosystem keyed on when they occurred: new models, price moves, context changes, deprecations, benchmark results, announcements. Filter by category, type, entity type and importance.',18  alternates: { canonical: '/changes' },19};20export const revalidate = 60;2122type SP = Record<string, string | undefined>;23const KEYS = ['category', 'type', 'entity_type', 'importance_min', 'since', 'until', 'q', 'include_backfill', 'date_field', 'entity'] as const;24const LIMIT = 50;25const ENTITY_TYPES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'dataset', 'artifact', 'model_family'];2627export default async function ChangesPage({ searchParams }: { searchParams: Promise<SP> }) {28  const sp = await searchParams;29  const current: Record<string, string | undefined> = {};30  for (const k of KEYS) if (sp[k]) current[k] = sp[k];31  if (current.include_backfill !== '1') delete current.include_backfill;32  if (current.date_field !== 'observed') delete current.date_field;33  const dateField: 'occurred' | 'observed' = current.date_field === 'observed' ? 'observed' : 'occurred';34  const [page, cats, meth] = await Promise.all([safe(apiD3.changes({ ...current, limit: LIMIT })), safe(api.changesCategories(7)), safe(apiD3.methodology())]);35  const href = (patch: Record<string, string | number | undefined | null>) => withParams('/changes', current, patch);36  const qs = new URLSearchParams(Object.entries(current).filter(([, v]) => v) as [string, string][]).toString();37  const groups = groupByDay(page?.items ?? [], dateField);38  const cursor = page && page.items.length >= LIMIT ? page.next_before ?? (dateField === 'occurred' ? page.items[page.items.length - 1]?.occurred_at ?? page.items[page.items.length - 1]?.observed_at : page.items[page.items.length - 1]?.observed_at) ?? null : null;39  const catCounts = new Map<string, number>();40  const typeCounts = new Map<string, number>();41  for (const c of cats?.items ?? []) {42    catCounts.set(c.category, (catCounts.get(c.category) ?? 0) + (num(c.count) ?? 0));43    typeCounts.set(c.event_type, (typeCounts.get(c.event_type) ?? 0) + (num(c.count) ?? 0));44  }45  const catOptions = [...catCounts.entries()].sort((a, b) => b[1] - a[1]);46  const typeOptions = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]);47  const today = new Date().toISOString().slice(0, 10);48  const activeCount = Object.keys(current).filter((k) => k !== 'date_field').length;49  const sem = meth?.event_semantics ?? {};5051  const filters = (52    <RailFilters53      action="/changes"54      resetHref={routes.changes()}55      testId="changes"56      fields={[57        { kind: 'select', name: 'category', label: 'Category', value: current.category, options: catOptions.map(([v, n]) => ({ value: v, label: `${categoryLabel(v)} (${fmtInt(n)})` })) },58        { kind: 'select', name: 'type', label: 'Event type', value: current.type, options: typeOptions.map(([v, n]) => ({ value: v, label: `${eventLabel(v)} (${fmtInt(n)})` })) },59        { kind: 'select', name: 'entity_type', label: 'Entity type', value: current.entity_type, options: ENTITY_TYPES.map((t) => ({ value: t, label: typeLabel(t, true) })) },60        { kind: 'select', name: 'importance_min', label: 'Min importance', value: current.importance_min, options: [3, 2, 1].map((n) => ({ value: String(n), label: `${IMPORTANCE_LABELS[n]} (≥ ${n})` })) },61        { kind: 'row', fields: [{ kind: 'date', name: 'since', label: 'Since', value: current.since, max: today }, { kind: 'date', name: 'until', label: 'Until', value: current.until, max: today }] },62        { kind: 'text', name: 'q', label: 'Text in summary', value: current.q, placeholder: 'e.g. context' },63        { kind: 'checkbox', name: 'include_backfill', label: 'Include historical backfill', checked: current.include_backfill === '1', hint: sem.is_backfill ?? 'History imported when a source is first crawled; never shown as “today” in feeds.' },64        { kind: 'checkbox', name: 'date_field', value: 'observed', label: 'Order by observation time', checked: dateField === 'observed', hint: 'Default order is occurred_at (effective date when a source states it). Tick to order by when AI Atlas first saw each change (v1 behaviour).' },65        ...(current.entity ? [{ kind: 'hidden' as const, name: 'entity', value: current.entity }] : []),66      ]}67    />68  );6970  const inspector = (71    <div className="space-y-4 text-sm">72      <div>73        <p className="eyebrow mb-1.5">Last 7 days by category</p>74        {catOptions.length === 0 ? (75          <p className="text-xs text-ink-3">No category counts available.</p>76        ) : (77          <ul className="space-y-0.5">78            {catOptions.slice(0, 10).map(([c, n]) => (79              <li key={c} className="flex items-center justify-between gap-2">80                <Link href={href({ category: c })} className="text-ink-2 hover:text-ink">81                  {categoryLabel(c)}82                </Link>83                <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>84              </li>85            ))}86          </ul>87        )}88      </div>89      <div>90        <p className="eyebrow mb-1.5">Semantics</p>91        <dl className="kv [&>div]:py-1 text-xs">92          {(['occurred_at', 'observed_at', 'recorded_at', 'is_backfill', 'group_key'] as const).filter((k) => sem[k]).map((k) => (93            <div key={k}>94              <dt className="mono">{k}</dt>95              <dd className="text-ink-2">{sem[k]}</dd>96            </div>97          ))}98          {!Object.keys(sem).length && <div><dd className="text-ink-3">Definitions unavailable.</dd></div>}99        </dl>100      </div>101      <p className="text-xs text-ink-3">102        <Link href={routes.changesDay(today)} className="link">Today in AI →</Link> · <Link href={routes.timeline()} className="link">Timeline</Link> · <Link href={routes.diff()} className="link">Diff two dates</Link>103      </p>104    </div>105  );106107  return (108    <>109      <Container wide>110        <PageHeader eyebrow="Changes" title="What changed in AI" lede="Every material change the connectors observe becomes an event with a source. Dates are when the change occurred (effective date when known); hover a date for the observation time. Older events load with a cursor." aside={<Link href={routes.changesDay(today)} className="link text-sm">Today in AI →</Link>} className="pb-3">111          <ActiveFilters current={current} labels={{ category: 'category', type: 'type', importance_min: 'importance ≥', since: 'since', until: 'until', q: 'text', entity_type: 'entity type', include_backfill: 'backfill', entity: 'entity', date_field: 'order' }} makeHref={(p) => href(p)} className="mt-3" />112        </PageHeader>113      </Container>114      <div className="pb-16">115        <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Filters" inspectorTitle="Context" storageKey="aia-changes-inspector" filterCount={activeCount}>116          {!page ? (117            <Unavailable what="Change feed" />118          ) : page.items.length === 0 ? (119            <EmptyState title="No events match these filters">The change engine only emits events when a source states a material change — try widening the filters{current.include_backfill ? '' : ', or include historical backfill'}.</EmptyState>120          ) : (121            <>122              <p className="tnum text-xs text-ink-3" data-changes-total>123                {fmtInt(page.total)} events{current.since ? ` since ${fmtDate(current.since)}` : ''}{current.until ? ` until ${fmtDate(current.until)}` : ''} · ordered by {dateField === 'occurred' ? 'occurrence' : 'observation'}{page.include_backfill ? ' · backfill included' : ''}124              </p>125              {groups.map((g) => (126                <section key={g.day} className="mt-6">127                  <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0">128                    <Link href={routes.changesDay(g.day)} className="hover:text-ink">129                      {fmtDate(g.day)}130                    </Link>{' '}131                    <span className="tnum text-ink-3">{g.items.length}</span>132                  </h2>133                  <ul className="border-t border-rule">134                    {g.items.map((e) => (135                      <ChangeRow key={e.id} e={e} showDate />136                    ))}137                  </ul>138                </section>139              ))}140              <LoadMore qs={qs} initialCursor={cursor} lastDay={groups[groups.length - 1]?.day ?? null} dateField={dateField} />141              <Note className="mt-4">Feed keyed on <span className="mono">{dateField}_at</span>. Backfill = history imported when a source is first crawled{current.include_backfill ? ' (included)' : ' (excluded — tick the box in the rail to include it)'}. Times are UTC.</Note>142            </>143          )}144        </TerminalLayout>145      </div>146    </>147  );148}149