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%
7.1 KB · 146 lines tsx
Raw Blame History
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