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