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%
6.2 KB · 120 lines tsx
Raw Blame History
1import { ExternalLink } from 'lucide-react';2import Link from 'next/link';3import { EntityBadge, ImportanceMark } from '@/components/ui/badges';4import { LiveAgo } from '@/components/ui/live';5import { cn } from '@/lib/cn';6import { fmtDate, fmtDateTime, fmtUsdPerM, fmtValue } from '@/lib/format';7import { eventLabel, eventTone, propertyLabel, routes } from '@/lib/site';8import type { ChangeEvent } from '@/lib/types';910const TONE: Record<ReturnType<typeof eventTone>, string> = {11  new: 'text-positive',12  price: 'text-accent-2',13  warn: 'text-warning',14  danger: 'text-danger',15  bench: 'text-type-benchmark',16  neutral: 'text-ink-2',17};1819function host(url: string): string {20  try {21    return new URL(url).hostname.replace(/^www\./, '');22  } catch {23    return 'source';24  }25}2627/** Price payloads (`PRICE_CHANGED`, `PROVIDER_LISTED`) are `{ input_per_mtok, output_per_mtok }` objects — never JSON-dump them. */28function fmtSide(v: unknown, key?: string): string {29  if (v && typeof v === 'object' && !Array.isArray(v)) {30    const o = v as Record<string, unknown>;31    if ('input_per_mtok' in o || 'output_per_mtok' in o) return `${fmtUsdPerM(o.input_per_mtok)} in / ${fmtUsdPerM(o.output_per_mtok)} out`;32    const parts = Object.entries(o)33      .filter(([, x]) => x !== null && x !== undefined && typeof x !== 'object')34      .slice(0, 3)35      .map(([k, x]) => `${propertyLabel(k).toLowerCase()} ${fmtValue(x, k)}`);36    return parts.length ? parts.join(' · ') : fmtValue(v, key);37  }38  return fmtValue(v, key);39}4041/** Old → new rendering for property changes and prices. */42export function Delta({ e }: { e: ChangeEvent }) {43  if (e.old_value === undefined || e.old_value === null || e.new_value === undefined || e.new_value === null) return null;44  const key = e.property ?? undefined;45  return (46    <span className="tnum inline-flex min-w-0 flex-wrap items-center gap-1 break-words text-xs">47      <span className="text-ink-3 line-through decoration-ink-3/60">{fmtSide(e.old_value, key)}</span>48      <span className="text-ink-3">→</span>49      <span className="font-medium text-ink">{fmtSide(e.new_value, key)}</span>50    </span>51  );52}5354/**55 * One event in a feed: importance meter · time · entity badge + name · summary · property delta · source.56 * `dense` (homepage) hides the delta and connector; `showDate` prints the date instead of relative time.57 */58export function ChangeRow({ e, dense = false, showDate = false, className, live = true, trailing }: { e: ChangeEvent; dense?: boolean; showDate?: boolean; className?: string; live?: boolean; /** Extra node after the summary (D3: grouped sources, evidence links). */ trailing?: React.ReactNode }) {59  const tone = eventTone(e.event_type);60  // 1.1: occurred_at = coalesce(effective_at, observed_at); the title always tells when AI Atlas observed it.61  const when = e.occurred_at ?? e.effective_at ?? e.observed_at;62  const observedTitle = `observed ${fmtDateTime(e.observed_at)}${e.effective_at ? ` · effective ${fmtDate(e.effective_at)}` : ''}`;63  return (64    <li className={cn('row-y grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 border-b border-rule md:grid-cols-[5.5rem_auto_minmax(0,1fr)_auto] md:items-baseline', className)} data-event-id={e.id}>65      <div className="col-span-2 flex items-center gap-2 text-xs text-ink-3 md:col-span-1 md:block">66        {showDate ? <time dateTime={when} title={observedTitle}>{fmtDate(when)}</time> : live ? <LiveAgo at={e.observed_at} /> : <time dateTime={e.observed_at} title={observedTitle}>{fmtDate(e.observed_at)}</time>}67        {e.is_backfill && <span className="ml-1 rounded-[3px] bg-surface-2 px-1 text-[10px] uppercase tracking-wide text-ink-3" title="Historical backfill: imported when the source was first crawled, not observed live">backfill</span>}68        <ImportanceMark importance={e.importance} className="md:hidden" />69      </div>70      <div className="hidden md:flex md:items-center md:self-center">71        <ImportanceMark importance={e.importance} />72      </div>73      <div className="col-span-2 min-w-0 md:col-span-1">74        <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">75          <span className={cn('text-[11px] font-medium uppercase tracking-wide', TONE[tone])}>{eventLabel(e.event_type)}</span>76          {e.entity && (77            <>78              <EntityBadge type={e.entity.entity_type} small />79              <Link href={routes.entity(e.entity)} className="max-w-full truncate text-[15px] font-medium text-ink hover:text-accent hover:underline">80                {e.entity.name}81              </Link>82              {e.entity.organization && <span className="max-w-full truncate text-xs text-ink-3">{e.entity.organization.name}</span>}83            </>84          )}85        </div>86        <p className={cn('mt-0.5 break-words text-sm text-ink-2 [overflow-wrap:anywhere]', dense && 'line-clamp-2')}>{e.summary}</p>87        {!dense && (88          <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5">89            {e.property && <span className="text-xs text-ink-3">{propertyLabel(e.property)}</span>}90            <Delta e={e} />91            {e.connector_name && <span className="mono text-[11px] text-ink-3">{e.connector_name}</span>}92          </div>93        )}94        {trailing}95      </div>96      <div className="col-span-2 mt-1 text-xs md:col-span-1 md:mt-0 md:text-right">97        {e.source_url ? (98          <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent">99            {host(e.source_url)} <ExternalLink className="size-3" aria-hidden />100          </a>101        ) : (102          <span className="text-ink-3">—</span>103        )}104      </div>105    </li>106  );107}108109/** Group events by UTC day (YYYY-MM-DD of `occurred_at` when present — 1.1 feeds — else observed_at). */110export function groupByDay(events: ChangeEvent[], field: 'occurred' | 'observed' = 'occurred'): { day: string; items: ChangeEvent[] }[] {111  const map = new Map<string, ChangeEvent[]>();112  for (const e of events) {113    const day = (field === 'occurred' ? e.occurred_at ?? e.effective_at ?? e.observed_at : e.observed_at).slice(0, 10);114    const arr = map.get(day);115    if (arr) arr.push(e);116    else map.set(day, [e]);117  }118  return [...map.entries()].map(([day, items]) => ({ day, items }));119}120