SPB Git

spb/ai-atlas

Public
4.6 KB · 102 lines tsx
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, 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/** Old → new rendering for property changes and prices. */28export function Delta({ e }: { e: ChangeEvent }) {29  if (e.old_value === undefined || e.old_value === null || e.new_value === undefined || e.new_value === null) return null;30  const key = e.property ?? undefined;31  return (32    <span className="tnum inline-flex flex-wrap items-center gap-1 text-xs">33      <span className="text-ink-3 line-through decoration-ink-3/60">{fmtValue(e.old_value, key)}</span>34      <span className="text-ink-3">→</span>35      <span className="font-medium text-ink">{fmtValue(e.new_value, key)}</span>36    </span>37  );38}3940/**41 * One event in a feed: importance meter · time · entity badge + name · summary · property delta · source.42 * `dense` (homepage) hides the delta and connector; `showDate` prints the date instead of relative time.43 */44export function ChangeRow({ e, dense = false, showDate = false, className, live = true }: { e: ChangeEvent; dense?: boolean; showDate?: boolean; className?: string; live?: boolean }) {45  const tone = eventTone(e.event_type);46  const when = e.effective_at ?? e.observed_at;47  return (48    <li className={cn('grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 border-b border-rule py-2.5 md:grid-cols-[5.5rem_auto_minmax(0,1fr)_auto] md:items-baseline', className)}>49      <div className="col-span-2 flex items-center gap-2 text-xs text-ink-3 md:col-span-1 md:block">50        {showDate ? <time dateTime={when} title={fmtDateTime(e.observed_at)}>{fmtDate(when)}</time> : live ? <LiveAgo at={e.observed_at} /> : <time dateTime={e.observed_at} title={fmtDateTime(e.observed_at)}>{fmtDate(e.observed_at)}</time>}51        <ImportanceMark importance={e.importance} className="md:hidden" />52      </div>53      <div className="hidden md:flex md:items-center md:self-center">54        <ImportanceMark importance={e.importance} />55      </div>56      <div className="col-span-2 min-w-0 md:col-span-1">57        <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">58          <span className={cn('text-[11px] font-medium uppercase tracking-wide', TONE[tone])}>{eventLabel(e.event_type)}</span>59          {e.entity && (60            <>61              <EntityBadge type={e.entity.entity_type} small />62              <Link href={routes.entity(e.entity)} className="truncate text-[15px] font-medium text-ink hover:text-accent hover:underline">63                {e.entity.name}64              </Link>65              {e.entity.organization && <span className="truncate text-xs text-ink-3">{e.entity.organization.name}</span>}66            </>67          )}68        </div>69        <p className={cn('mt-0.5 text-sm text-ink-2', dense && 'line-clamp-2')}>{e.summary}</p>70        {!dense && (71          <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5">72            {e.property && <span className="text-xs text-ink-3">{propertyLabel(e.property)}</span>}73            <Delta e={e} />74            {e.connector_name && <span className="mono text-[11px] text-ink-3">{e.connector_name}</span>}75          </div>76        )}77      </div>78      <div className="col-span-2 mt-1 text-xs md:col-span-1 md:mt-0 md:text-right">79        {e.source_url ? (80          <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent">81            {host(e.source_url)} <ExternalLink className="size-3" aria-hidden />82          </a>83        ) : (84          <span className="text-ink-3">—</span>85        )}86      </div>87    </li>88  );89}9091/** Group events by UTC day (YYYY-MM-DD of observed_at). */92export function groupByDay(events: ChangeEvent[]): { day: string; items: ChangeEvent[] }[] {93  const map = new Map<string, ChangeEvent[]>();94  for (const e of events) {95    const day = e.observed_at.slice(0, 10);96    const arr = map.get(day);97    if (arr) arr.push(e);98    else map.set(day, [e]);99  }100  return [...map.entries()].map(([day, items]) => ({ day, items }));101}102
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme