spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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