SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
8.2 KB · 168 lines tsx
Raw Blame History
1import type { EventDTO } from "@dci/core";2import { ArrowRight, ExternalLink } from "lucide-react";3import Link from "next/link";4import { AiMark, ConfidenceBadge, EventBadge, SignificanceBadge } from "@/components/ui/badge";5import { SourceBadge } from "@/components/ui/source-badge";6import { Tooltip } from "@/components/ui/popover";7import { cn } from "@/components/ui/cn";8import { CountryFlag } from "@/components/ui/country-flag";9import { RelativeTime } from "@/components/ui/relative-time";10import { EmptyState } from "@/components/ui/states";11import { dayKey, fmtDayHeading, fmtValue } from "@/lib/format";12import { ENTITY_TYPE_LABEL, SIGNIFICANCE_LABEL, SOURCE_KIND_LABEL, significanceBand } from "@/lib/labels";13import { entityHref } from "@/lib/routes";1415function valueHint(e: EventDTO): "mw" | "date" | "usd" | undefined {16  switch (e.eventType) {17    case "capacity_changed":18    case "planned_capacity_changed":19      return "mw";20    case "opening_date_changed":21      return "date";22    case "investment_announced":23      return "usd";24    default:25      return undefined;26  }27}2829/** "120 MW → 180 MW" */30export function ValueChange({ e, className }: { e: EventDTO; className?: string }) {31  const hint = valueHint(e);32  const hasOld = e.oldValue !== null && e.oldValue !== undefined && e.oldValue !== "";33  const hasNew = e.newValue !== null && e.newValue !== undefined && e.newValue !== "";34  if (!hasOld && !hasNew) return null;35  return (36    <span className={cn("figure inline-flex flex-wrap items-center gap-1.5 text-[12.5px]", className)}>37      {hasOld && <span className="text-ink-3 line-through decoration-ink-4">{fmtValue(e.oldValue, hint)}</span>}38      {hasOld && hasNew && <ArrowRight className="size-3 text-ink-4" aria-label="changed to" />}39      {hasNew && <span className="font-medium text-ink">{fmtValue(e.newValue, hint)}</span>}40    </span>41  );42}4344export function SignificanceMark({ value, band, className }: { value: number; band?: EventDTO["significanceBand"]; className?: string }) {45  const b = significanceBand(value, band);46  const level = b === "major" ? 3 : b === "medium" ? 2 : 1;47  return (48    <span className={cn("inline-flex items-end gap-[2px]", className)} title={`${SIGNIFICANCE_LABEL[b]} significance (${value}/100)`} aria-label={`${SIGNIFICANCE_LABEL[b]} significance, ${value} out of 100`}>49      {[0, 1, 2].map((i) => (50        <span key={i} className={cn("inline-block w-[3px] rounded-[1px]", i === 0 ? "h-[6px]" : i === 1 ? "h-[9px]" : "h-[12px]", i < level ? (level === 3 ? "bg-accent" : "bg-ink-2") : "bg-hair-2")} />51      ))}52    </span>53  );54}5556export function EventRow({ e, dense, highlight }: { e: EventDTO; dense?: boolean; highlight?: boolean }) {57  const href = e.entity ? entityHref(e.entityType, e.entity.slug, e.entity.name) : null;58  return (59    <li className={cn("relative grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 py-2.5", highlight && "animate-row-in", dense ? "py-2" : "py-3")}>60      <div className="flex flex-col items-center gap-1.5 pt-1">61        <SignificanceMark value={e.significance} band={e.significanceBand} />62      </div>63      <div className="min-w-0">64        <div className="flex flex-wrap items-center gap-x-2 gap-y-1">65          <EventBadge type={e.eventType} size="xs" />66          {significanceBand(e.significance, e.significanceBand) === "major" && <SignificanceBadge value={e.significance} band={e.significanceBand} />}67          {e.isAi && <AiMark />}68          {e.countryIso2 && <CountryFlag iso2={e.countryIso2} size={12} />}69          <span className="text-[11px] text-ink-3">{ENTITY_TYPE_LABEL[e.entityType] ?? e.entityType}</span>70          <RelativeTime iso={e.detectedAt} className="figure ml-auto text-[11px] text-ink-3" />71        </div>72        <div className="mt-1 text-[13.5px] leading-snug">73          {href && e.entity ? (74            // stretched link: the whole row is the tap target for the entity (secondary links sit above it via z-index)75            <Link href={href} className="font-medium text-ink hover:text-accent hover:underline after:absolute after:inset-0 after:content-['']">76              {e.entity.name}77            </Link>78          ) : (79            <span className="font-medium text-ink">{e.title}</span>80          )}81          {href && e.entity && e.title && e.title !== e.entity.name && <span className="text-ink-2"> — {e.title}</span>}82        </div>83        <ValueChange e={e} className="mt-1" />84        {!dense && e.summary && <p className="mt-1 line-clamp-2 text-[12.5px] text-ink-2">{e.summary}</p>}85        <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3">86          {e.operator && (87            <Link href={entityHref("operator", e.operator.slug)} className="relative z-[1] inline-flex min-h-6 items-center hover:text-ink">88              {e.operator.name}89            </Link>90          )}91          <span className="relative z-[1] inline-flex items-center gap-1.5">92            <SourceBadge kind={e.sourceKind} name={e.sourceName} url={e.url} publishedAt={e.effectiveDate} retrievedAt={e.detectedAt} documentId={e.documentId} />93            <a href={e.url} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-6 items-center gap-1 hover:text-accent" title={`${SOURCE_KIND_LABEL[e.sourceKind]} source`}>94              {e.sourceName}95              <ExternalLink className="size-3" />96            </a>97          </span>98          {(e.evidenceCount ?? 0) > 1 && <AlsoReported e={e} />}99          <ConfidenceBadge level={e.confidence} size="xs" className="ml-auto" />100        </div>101      </div>102    </li>103  );104}105106/** "also reported by 4 sources" with the other documents on hover (deduplicated cluster). */107function AlsoReported({ e }: { e: EventDTO }) {108  const n = (e.evidenceCount ?? 1) - 1;109  const others = e.otherSources ?? [];110  const label = `also reported by ${n} ${n === 1 ? "source" : "sources"}`;111  if (!others.length) return <span className="figure text-[11px] text-ink-3">{label}</span>;112  return (113    <Tooltip114      content={115        <ul className="flex flex-col gap-0.5">116          {others.slice(0, 6).map((o, i) => (117            <li key={i} className="truncate">118              {o.sourceName} <span className="opacity-70">· {SOURCE_KIND_LABEL[o.sourceKind]}</span>119            </li>120          ))}121          {others.length > 6 && <li className="opacity-70">+{others.length - 6} more</li>}122        </ul>123      }124    >125      <span className="relative z-[1] inline-flex min-h-6 items-center rounded-xs border border-hair px-1.5 text-[11px] text-ink-2">{label}</span>126    </Tooltip>127  );128}129130/** Flat list (home module) or grouped by day (live feed). */131export function EventList({ events, groupByDay, dense, highlightIds, emptyTitle = "No changes detected yet", emptyDescription = "New facilities, capacity and status changes will appear here as connectors run." }: { events: EventDTO[]; groupByDay?: boolean; dense?: boolean; highlightIds?: Set<string>; emptyTitle?: string; emptyDescription?: string }) {132  if (!events.length) return <EmptyState title={emptyTitle} description={emptyDescription} compact />;133  if (!groupByDay) {134    return (135      <ul className="divide-y divide-hair">136        {events.map((e) => (137          <EventRow key={e.id} e={e} dense={dense} highlight={highlightIds?.has(e.id)} />138        ))}139      </ul>140    );141  }142  const groups = new Map<string, EventDTO[]>();143  for (const e of events) {144    const k = dayKey(e.detectedAt);145    if (!groups.has(k)) groups.set(k, []);146    groups.get(k)!.push(e);147  }148  return (149    <div className="flex flex-col gap-4">150      {Array.from(groups.entries()).map(([day, list]) => (151        <section key={day} aria-label={fmtDayHeading(day)}>152          <div className="sticky top-12 z-[5] -mx-3 flex items-baseline gap-2 border-y border-hair bg-paper/95 px-3 py-1.5 backdrop-blur sm:top-14 sm:mx-0 sm:px-0">153            <h2 className="text-[12.5px] font-semibold text-ink">{fmtDayHeading(day)}</h2>154            <span className="figure text-[11px] text-ink-3">155              {day} · {list.length} {list.length === 1 ? "change" : "changes"}156            </span>157          </div>158          <ul className="divide-y divide-hair">159            {list.map((e) => (160              <EventRow key={e.id} e={e} dense={dense} highlight={highlightIds?.has(e.id)} />161            ))}162          </ul>163        </section>164      ))}165    </div>166  );167}168