'use client'; import Link from 'next/link'; import { useMemo, useState } from 'react'; import { ChangeRow } from '@/components/changes/change-row'; import { TimelineLanes, type LaneEvent } from '@/components/charts'; import { Hint } from '@/components/ui/hint'; import { fmtDate, fmtInt, fmtMonth } from '@/lib/format'; import { eventLabel, routes } from '@/lib/site'; import type { ChangeEvent } from '@/lib/types'; import { laneOf, occurredAt, TIMELINE_LANES } from './lanes'; import { MobileLaneStrip } from './mobile-lane-strip'; /** * Timeline 2.0 body (client): lanes chart (brushable) → the list below is filtered to the brushed range; month headings * stick under the header; per-month anchor nav. Everything rendered comes from the events the page fetched. */ export function TimelineWorkbench({ events, semantics, backfill }: { events: ChangeEvent[]; semantics?: Record | null; backfill: boolean }) { const [range, setRange] = useState<{ from: number; to: number } | null>(null); const lanes = useMemo(() => { const present = new Set(events.map(laneOf)); return TIMELINE_LANES.filter((l) => present.has(l.key)).map(({ key, label, color }) => ({ key, label, color })); }, [events]); const laneEvents: LaneEvent[] = useMemo( () => events.map((e) => ({ id: e.id, lane: laneOf(e), at: occurredAt(e), importance: e.importance, label: e.summary, sub: e.entity ? `${eventLabel(e.event_type)} · ${e.entity.name}` : eventLabel(e.event_type), href: e.entity ? routes.entity(e.entity) : undefined, })), [events], ); const [domFrom, domTo] = useMemo(() => { const ts = events.map((e) => new Date(occurredAt(e)).getTime()).filter((t) => Number.isFinite(t)); if (!ts.length) return [undefined, undefined] as const; const lo = Math.min(...ts); const hi = Math.max(...ts); // Deterministic domain (no Date.now()): pad a single-day span by one day on each side so SSR and hydration agree. return hi - lo < 86400000 ? ([lo - 86400000, lo + 86400000] as const) : ([lo, hi] as const); }, [events]); const filtered = useMemo(() => { if (!range) return events; return events.filter((e) => { const t = new Date(occurredAt(e)).getTime(); return t >= range.from && t <= range.to; }); }, [events, range]); const months = useMemo(() => { const map = new Map(); for (const e of filtered) { const m = occurredAt(e).slice(0, 7); (map.get(m) ?? map.set(m, []).get(m)!).push(e); } return [...map.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1)).map(([month, items]) => ({ month, items })); }, [filtered]); return (
Lanes · dots sized by importance · drag to select a range occurred vs observed vs recorded Occurred — {semantics?.occurred_at ?? 'coalesce(effective_at, observed_at): when the change happened (effective date when a source states it).'} Observed — {semantics?.observed_at ?? 'when AI Atlas first saw the change.'} Recorded — {semantics?.recorded_at ?? 'when the row was written.'} Backfill — {semantics?.is_backfill ?? 'history imported when a source is first crawled; never shown as “today”.'} } /> {backfill && including historical backfill}
setRange({ from: a.getTime(), to: b.getTime() })} title="Events by lane" />

{range ? ( <> Range {fmtDate(new Date(range.from).toISOString())} → {fmtDate(new Date(range.to).toISOString())} · {fmtInt(filtered.length)} of {fmtInt(events.length)} loaded events ) : ( {fmtInt(events.length)} events loaded · every dot is one event; hover for its summary )}

{months.length === 0 &&

No events in the selected range.

} {months.map((m) => (

{fmtMonth(m.month)} {fmtInt(m.items.length)} digest →

    {m.items.map((e) => ( ))}
))}
); }