HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import Link from 'next/link';3import { useMemo, useState } from 'react';4import { ChangeRow } from '@/components/changes/change-row';5import { TimelineLanes, type LaneEvent } from '@/components/charts';6import { Hint } from '@/components/ui/hint';7import { fmtDate, fmtInt, fmtMonth } from '@/lib/format';8import { eventLabel, routes } from '@/lib/site';9import type { ChangeEvent } from '@/lib/types';10import { laneOf, occurredAt, TIMELINE_LANES } from './lanes';11import { MobileLaneStrip } from './mobile-lane-strip';1213/**14 * Timeline 2.0 body (client): lanes chart (brushable) → the list below is filtered to the brushed range; month headings15 * stick under the header; per-month anchor nav. Everything rendered comes from the events the page fetched.16 */17export function TimelineWorkbench({ events, semantics, backfill }: { events: ChangeEvent[]; semantics?: Record<string, string> | null; backfill: boolean }) {18 const [range, setRange] = useState<{ from: number; to: number } | null>(null);19 const lanes = useMemo(() => {20 const present = new Set(events.map(laneOf));21 return TIMELINE_LANES.filter((l) => present.has(l.key)).map(({ key, label, color }) => ({ key, label, color }));22 }, [events]);23 const laneEvents: LaneEvent[] = useMemo(24 () =>25 events.map((e) => ({26 id: e.id,27 lane: laneOf(e),28 at: occurredAt(e),29 importance: e.importance,30 label: e.summary,31 sub: e.entity ? `${eventLabel(e.event_type)} · ${e.entity.name}` : eventLabel(e.event_type),32 href: e.entity ? routes.entity(e.entity) : undefined,33 })),34 [events],35 );36 const [domFrom, domTo] = useMemo(() => {37 const ts = events.map((e) => new Date(occurredAt(e)).getTime()).filter((t) => Number.isFinite(t));38 if (!ts.length) return [undefined, undefined] as const;39 const lo = Math.min(...ts);40 const hi = Math.max(...ts);41 // Deterministic domain (no Date.now()): pad a single-day span by one day on each side so SSR and hydration agree.42 return hi - lo < 86400000 ? ([lo - 86400000, lo + 86400000] as const) : ([lo, hi] as const);43 }, [events]);44 const filtered = useMemo(() => {45 if (!range) return events;46 return events.filter((e) => {47 const t = new Date(occurredAt(e)).getTime();48 return t >= range.from && t <= range.to;49 });50 }, [events, range]);51 const months = useMemo(() => {52 const map = new Map<string, ChangeEvent[]>();53 for (const e of filtered) {54 const m = occurredAt(e).slice(0, 7);55 (map.get(m) ?? map.set(m, []).get(m)!).push(e);56 }57 return [...map.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1)).map(([month, items]) => ({ month, items }));58 }, [filtered]);5960 return (61 <div className="space-y-6" data-timeline-workbench>62 <section aria-label="Lanes">63 <div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">64 <span className="eyebrow">65 Lanes <span className="normal-case tracking-normal text-ink-3">· dots sized by importance · drag to select a range</span>66 </span>67 <span className="inline-flex items-center gap-1">68 occurred vs observed vs recorded69 <Hint70 align="right"71 text={72 <span className="block space-y-1 text-left">73 <span className="block">74 <b>Occurred</b> — {semantics?.occurred_at ?? 'coalesce(effective_at, observed_at): when the change happened (effective date when a source states it).'}75 </span>76 <span className="block">77 <b>Observed</b> — {semantics?.observed_at ?? 'when AI Atlas first saw the change.'}78 </span>79 <span className="block">80 <b>Recorded</b> — {semantics?.recorded_at ?? 'when the row was written.'}81 </span>82 <span className="block">83 <b>Backfill</b> — {semantics?.is_backfill ?? 'history imported when a source is first crawled; never shown as “today”.'}84 </span>85 </span>86 }87 />88 </span>89 {backfill && <span className="rounded-[3px] bg-surface-2 px-1.5 text-[10px] uppercase tracking-wide">including historical backfill</span>}90 </div>91 <div className="hidden md:block">92 <TimelineLanes lanes={lanes} events={laneEvents} from={domFrom} to={domTo} brushable onRange={(a, b) => setRange({ from: a.getTime(), to: b.getTime() })} title="Events by lane" />93 </div>94 <div className="md:hidden">95 <MobileLaneStrip events={events} />96 </div>97 <p className="tnum mt-1 flex flex-wrap items-center gap-x-3 text-xs text-ink-3">98 {range ? (99 <>100 <span className="text-ink-2">101 Range {fmtDate(new Date(range.from).toISOString())} → {fmtDate(new Date(range.to).toISOString())} · {fmtInt(filtered.length)} of {fmtInt(events.length)} loaded events102 </span>103 <button type="button" onClick={() => setRange(null)} className="link">104 Clear range105 </button>106 </>107 ) : (108 <span>{fmtInt(events.length)} events loaded · every dot is one event; hover for its summary</span>109 )}110 </p>111 </section>112113 <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]">114 <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:max-h-[70vh] lg:space-y-0.5 lg:self-start lg:overflow-y-auto lg:px-0">115 {months.map((m) => (116 <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">117 <span>{fmtMonth(m.month)}</span>118 <span className="text-ink-3">{fmtInt(m.items.length)}</span>119 </a>120 ))}121 </nav>122 <div className="min-w-0">123 {months.length === 0 && <p className="border-y border-rule py-8 text-center text-sm text-ink-3">No events in the selected range.</p>}124 {months.map((m) => (125 <section key={m.month} id={`m-${m.month}`} className="scroll-mt-24 pb-8">126 <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">127 <span>128 {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.items.length)}</span>129 </span>130 <Link href={routes.changesDay(`${m.month}-01`)} className="normal-case tracking-normal text-ink-3 hover:text-ink">131 digest →132 </Link>133 </h2>134 <ul className="border-t border-rule">135 {m.items.map((e) => (136 <ChangeRow key={e.id} e={e} showDate live={false} />137 ))}138 </ul>139 </section>140 ))}141 </div>142 </div>143 </div>144 );145}146