spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { FacilityStatus, ProjectSummary } from "@dci/core";2import { partialDateSortKey } from "@dci/core";3import Link from "next/link";4import { AiMark, EstimateMark, PrecisionBadge, ScopeBadge, StatusBadge } from "@/components/ui/badge";5import { cn } from "@/components/ui/cn";6import { CountryFlag } from "@/components/ui/country-flag";7import { EmptyState } from "@/components/ui/states";8import { StatusDot } from "@/components/ui/status-dot";9import { fmtInt, fmtMw, fmtUsd, placeLine } from "@/lib/format";10import { fmtPartialDateSafe } from "@/lib/format-project";11import { STATUS_SHORT, isSiteScopeLabel } from "@/lib/labels";12import { routes } from "@/lib/routes";1314/* ------------------------------------------------------------------ shared dense card */1516function mwCell(p: ProjectSummary) {17 if (p.plannedMw === null) return <span className="text-ink-4">—</span>;18 const site = p.capacityScope === null || p.capacityScope === undefined || isSiteScopeLabel(p.capacityScope);19 if (!site) return <span className="text-ink-4" title={`${fmtMw(p.plannedMw)} is a ${p.capacityScope}-level figure, not this site's`}>— <ScopeBadge scope={p.capacityScope} className="align-middle" /></span>;20 return (21 <span>22 <EstimateMark className="mr-0.5" />23 {fmtMw(p.plannedMw)}24 </span>25 );26}2728export function ProjectCard({ p, showStatus = true, className }: { p: ProjectSummary; showStatus?: boolean; className?: string }) {29 return (30 <article className={cn("relative rounded-sm border border-hair bg-surface px-2.5 py-2 text-[12.5px]", className)}>31 <div className="flex items-start justify-between gap-2">32 <Link href={routes.project(p.slug)} className="min-w-0 font-medium leading-snug text-ink hover:text-accent after:absolute after:inset-0 after:content-['']">33 <span className="line-clamp-2">{p.name}</span>34 </Link>35 <span className="figure shrink-0 text-[13px] font-medium text-ink">{mwCell(p)}</span>36 </div>37 <div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11.5px] text-ink-3">38 {p.operator ? <span className="truncate text-ink-2">{p.operator.name}</span> : <span>Operator unknown</span>}39 <span aria-hidden>·</span>40 <CountryFlag iso2={p.countryIso2} size={11} />41 <span className="truncate">{p.metro?.name ?? placeLine(p)}</span>42 </div>43 <div className="mt-1.5 flex flex-wrap items-center gap-1">44 {showStatus && <StatusBadge status={p.status} short size="xs" />}45 {p.isAi && <AiMark evidence={p.aiEvidence} />}46 {p.geoPrecision && p.geoPrecision !== "unknown" && p.geoPrecision !== "exact" && <PrecisionBadge precision={p.geoPrecision} size="xs" />}47 <span className="figure ml-auto text-[11px] text-ink-3" title="Expected opening">48 {p.expectedOpening ? fmtPartialDateSafe(p.expectedOpening) : "TBD"}49 </span>50 </div>51 </article>52 );53}5455/* ------------------------------------------------------------------ TIMELINE */5657/** Projects grouped by expected opening year (undated last), each year with its published-MW sum. */58export function ProjectsTimeline({ items }: { items: ProjectSummary[] }) {59 if (!items.length) return <EmptyState compact title="No projects match these filters" />;60 const groups = new Map<string, ProjectSummary[]>();61 for (const p of items) {62 const k = p.expectedOpening ? p.expectedOpening.slice(0, 4) : "TBD";63 if (!groups.has(k)) groups.set(k, []);64 groups.get(k)!.push(p);65 }66 const keys = Array.from(groups.keys()).sort((a, b) => (a === "TBD" ? 1 : b === "TBD" ? -1 : a.localeCompare(b)));67 return (68 <ol className="relative ml-2 border-l border-hair">69 {keys.map((k) => {70 const list = groups.get(k)!.sort((a, b) => partialDateSortKey((a.expectedOpening ?? "9999").slice(0, 10)) - partialDateSortKey((b.expectedOpening ?? "9999").slice(0, 10)));71 const mw = list.filter((p) => p.plannedMw !== null && (p.capacityScope == null || isSiteScopeLabel(p.capacityScope))).reduce((a, p) => a + (p.plannedMw ?? 0), 0);72 const withMw = list.filter((p) => p.plannedMw !== null).length;73 return (74 <li key={k} className="relative pb-6 pl-5 last:pb-0">75 <span aria-hidden className={cn("absolute -left-[5px] top-[7px] size-[9px] rounded-full ring-2 ring-paper", k === "TBD" ? "border border-hair-2 bg-paper" : "bg-ink")} />76 <div className="mb-2 flex flex-wrap items-baseline gap-x-3 gap-y-0.5">77 <h3 className="figure text-[16px] font-semibold text-ink">{k === "TBD" ? "Expected opening not published" : k}</h3>78 <span className="figure text-[12px] text-ink-3">79 {fmtInt(list.length)} {list.length === 1 ? "project" : "projects"}80 {mw > 0 && <> · ≈ {fmtMw(mw)} published ({fmtInt(withMw)} of {fmtInt(list.length)} with a figure)</>}81 </span>82 </div>83 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">84 {list.map((p) => (85 <ProjectCard key={p.id} p={p} />86 ))}87 </div>88 </li>89 );90 })}91 </ol>92 );93}9495/* ------------------------------------------------------------------ KANBAN */9697const KANBAN: FacilityStatus[] = ["rumored", "proposed", "announced", "permitting", "approved", "under_construction", "partially_operational", "delayed", "cancelled"];9899export function ProjectsKanban({ items, counts }: { items: ProjectSummary[]; counts?: Partial<Record<FacilityStatus, number>> }) {100 if (!items.length) return <EmptyState compact title="No projects match these filters" />;101 const cols = KANBAN.map((s) => ({ s, list: items.filter((p) => p.status === s || (s === "announced" && p.status === "expansion")) })).filter((c) => c.list.length || (counts?.[c.s] ?? 0) > 0);102 return (103 <div className="-mx-3 overflow-x-auto px-3 pb-2 no-scrollbar sm:mx-0 sm:px-0">104 <ol className="flex min-w-max gap-3" aria-label="Projects by stage">105 {cols.map((c) => {106 const mw = c.list.filter((p) => p.plannedMw !== null && (p.capacityScope == null || isSiteScopeLabel(p.capacityScope))).reduce((a, p) => a + (p.plannedMw ?? 0), 0);107 return (108 <li key={c.s} className="w-[260px] shrink-0 sm:w-[280px]">109 <div className="mb-2 flex items-baseline gap-2 border-b-2 pb-1.5" style={{ borderColor: `var(--st-${c.s === "under_construction" ? "construction" : c.s === "announced" || c.s === "proposed" ? "planned" : c.s === "approved" || c.s === "permitting" ? "permitting" : c.s === "partially_operational" ? "operational" : c.s === "delayed" ? "delayed" : c.s === "cancelled" ? "closed" : "rumored"})` }}>110 <StatusDot status={c.s} size={7} />111 <h3 className="text-[12.5px] font-semibold uppercase tracking-[0.06em] text-ink">{STATUS_SHORT[c.s]}</h3>112 <span className="figure text-[11.5px] text-ink-3">113 {fmtInt(counts?.[c.s] ?? c.list.length)}114 {mw > 0 && ` · ≈ ${fmtMw(mw)}`}115 </span>116 </div>117 <div className="flex flex-col gap-2">118 {c.list.length ? c.list.map((p) => <ProjectCard key={p.id} p={p} showStatus={false} />) : <p className="rounded-sm border border-dashed border-hair-2 px-2.5 py-3 text-[12px] text-ink-4">None on this page — {fmtInt(counts?.[c.s] ?? 0)} in total.</p>}119 </div>120 </li>121 );122 })}123 </ol>124 </div>125 );126}127128/* ------------------------------------------------------------------ helper used by the map view list */129export function investmentCell(p: ProjectSummary) {130 if (p.investmentUsd === null) return "—";131 const site = p.investmentScope == null || isSiteScopeLabel(p.investmentScope);132 return site ? fmtUsd(p.investmentUsd) : "—";133}134