SPB Git

spb/ai-atlas

Public
8.8 KB · 163 lines tsx
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ChangeRow } from '@/components/changes/change-row';4import { Bars } from '@/components/charts/charts';5import { ChipRow, type ChipItem } from '@/components/timeline/chip-row';6import { EntityBadge } from '@/components/ui/badges';7import { Container, Note, PageHeader } from '@/components/ui/section';8import { EmptyState, Unavailable } from '@/components/ui/unavailable';9import { api, safe } from '@/lib/api';10import { fmtInt, fmtMonth, fmtYear } from '@/lib/format';11import { CATEGORY_LABELS, categoryLabel, routes, SITE_NAME } from '@/lib/site';1213export const revalidate = 600;1415type SP = Record<string, string | undefined>;16const LIMIT = 400;1718function current(sp: SP) {19  const out: { entity?: string; year?: string; category?: string } = {};20  if (sp.entity) out.entity = sp.entity;21  if (sp.year && /^\d{4}$/.test(sp.year)) out.year = sp.year;22  if (sp.category) out.category = sp.category;23  return out;24}2526export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {27  const cur = current(await searchParams);28  const bits: string[] = [];29  if (cur.entity) bits.push(cur.entity);30  if (cur.category) bits.push(categoryLabel(cur.category).toLowerCase());31  if (cur.year) bits.push(cur.year);32  const title = bits.length ? `Timeline — ${bits.join(' · ')}` : 'Timeline — the AI ecosystem month by month';33  return {34    title,35    description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity, each linked to its source.',36    alternates: { canonical: routes.timeline(cur) },37    openGraph: { title: `${title} | ${SITE_NAME}` },38    robots: cur.entity ? { index: false, follow: true } : undefined,39  };40}4142export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) {43  const sp = await searchParams;44  const cur = current(sp);45  const [res, cats, stats, entity] = await Promise.all([safe(api.timeline({ ...cur, limit: LIMIT })), safe(api.changesCategories(365)), safe(api.stats()), cur.entity ? safe(api.entity(cur.entity)) : Promise.resolve(null)]);46  const months = res?.items ?? [];47  const shown = months.reduce((n, m) => n + m.events.length, 0);48  const total = res?.total ?? shown;4950  // Years: current UTC year back to the earliest year the atlas knows (first entity or earliest month returned).51  const thisYear = new Date().getUTCFullYear();52  const earliestCandidates = [stats?.first_entity_at, ...months.map((m) => m.month), cur.year].filter((v): v is string => !!v).map((v) => Number(fmtYear(v))).filter((n) => Number.isFinite(n) && n > 1990);53  const earliest = earliestCandidates.length ? Math.min(...earliestCandidates) : thisYear;54  const years: string[] = [];55  for (let y = thisYear; y >= earliest; y--) years.push(String(y));5657  // Categories: live counts over the last year, summed per category; fall back to the vocabulary when unavailable.58  const catCounts = new Map<string, number>();59  for (const it of cats?.items ?? []) catCounts.set(it.category, (catCounts.get(it.category) ?? 0) + (Number(it.count) || 0));60  const catKeys = catCounts.size ? [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k) : Object.keys(CATEGORY_LABELS);61  if (cur.category && !catKeys.includes(cur.category)) catKeys.push(cur.category);6263  const yearChips: ChipItem[] = [{ href: routes.timeline({ ...cur, year: undefined }), label: 'All years', active: !cur.year }, ...years.map((y) => ({ href: routes.timeline({ ...cur, year: y }), label: y, active: cur.year === y }))];64  const catChips: ChipItem[] = [{ href: routes.timeline({ ...cur, category: undefined }), label: 'All categories', active: !cur.category }, ...catKeys.map((c) => ({ href: routes.timeline({ ...cur, category: c }), label: categoryLabel(c), count: catCounts.size ? catCounts.get(c) ?? 0 : undefined, active: cur.category === c }))];6566  // Density strip, oldest → newest (the list below is newest first).67  const density = [...months].reverse().map((m) => ({ label: fmtMonth(m.month), value: m.count ?? m.events.length }));6869  return (70    <Container>71      <PageHeader72        eyebrow={73          entity ? (74            <>75              <EntityBadge type={entity.entity_type} small /> Timeline76            </>77          ) : (78            'Timeline'79          )80        }81        title={82          entity ? (83            <>84              Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link>85            </>86          ) : cur.entity ? (87            <>Timeline of <span className="mono text-ink-2">{cur.entity}</span></>88          ) : (89            'The ecosystem, month by month'90          )91        }92        lede={entity ? <>Every recorded change for this {entity.entity_type.replace(/_/g, ' ')}{entity.organization ? ` by ${entity.organization.name}` : ''}, newest first. <Link href={routes.entity(entity)} className="link">Back to the entity page →</Link></> : 'Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change.'}93        aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(shown)}{total > shown ? ` of ${fmtInt(total)}` : ''} events</p> : undefined}94      >95        <div className="mt-6 space-y-3">96          <div>97            <p className="eyebrow mb-1.5">Year</p>98            <ChipRow label="Year" items={yearChips} />99          </div>100          <div>101            <p className="eyebrow mb-1.5">Category {cats ? <span className="normal-case tracking-normal text-ink-3">· events in the last 365 days</span> : null}</p>102            <ChipRow label="Category" items={catChips} />103          </div>104          {cur.entity && (105            <p className="text-xs text-ink-3">106              Filtered to one entity. <Link href={routes.timeline({ year: cur.year, category: cur.category })} className="link">Show the whole atlas</Link>107            </p>108          )}109        </div>110      </PageHeader>111112      <div className="pb-16">113        {!res ? (114          <Unavailable what="Timeline" reason={cur.entity ? 'The entity slug may not exist.' : undefined} />115        ) : months.length === 0 ? (116          <EmptyState title="No events for this selection">117            Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>.118          </EmptyState>119        ) : (120          <>121            {density.length > 1 && (122              <section aria-label="Events per month" className="mb-8">123                <p className="eyebrow mb-2">Events per month <span className="tnum normal-case tracking-normal text-ink-3">· {fmtInt(density.length)} months shown</span></p>124                <Bars data={density} height={90} />125              </section>126            )}127            <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]">128              <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0">129                {months.map((m) => (130                  <a key={m.month} href={`#m-${m.month}`} className="tnum flex h-9 shrink-0 items-center justify-between gap-2 border border-rule px-2 text-xs text-ink-2 hover:text-ink lg:h-8 lg:border-0 lg:px-1">131                    <span>{fmtMonth(m.month)}</span>132                    <span className="text-ink-3">{fmtInt(m.count ?? m.events.length)}</span>133                  </a>134                ))}135              </nav>136              <div className="min-w-0">137                {months.map((m) => (138                  <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8">139                    <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 flex items-baseline justify-between bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0">140                      <span>141                        {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.count ?? m.events.length)}</span>142                      </span>143                      {(m.count ?? m.events.length) > m.events.length && <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(m.events.length)} shown</span>}144                    </h2>145                    <ul className="border-t border-rule">146                      {m.events.map((e) => (147                        <ChangeRow key={e.id} e={e} showDate live={false} />148                      ))}149                    </ul>150                  </section>151                ))}152                <Note>153                  Showing up to {fmtInt(LIMIT)} events{total > shown ? ` of ${fmtInt(total)} matching` : ''}. Narrow by year or category, or use <Link href={routes.changes()} className="link">the changes feed</Link> for cursor-paged history. Times are UTC.154                </Note>155              </div>156            </div>157          </>158        )}159      </div>160    </Container>161  );162}163
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme