spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1import type { FacilityStatus } from "@dci/core";2import { Activity, Columns3, Map as MapIcon, Table2 } from "lucide-react";3import type { Metadata } from "next";4import Link from "next/link";5import { Suspense } from "react";6import { PipelineByYear } from "@/components/home/charts";7import { PipelineFunnel } from "@/components/home/pipeline-funnel";8import { AiMark, ConfidenceBadge, EstimateMark, PrecisionBadge, ScopeBadge, StatusBadge } from "@/components/ui/badge";9import { Page, PageHeader } from "@/components/ui/card";10import { BarList } from "@/components/ui/charts";11import { CountryFlag } from "@/components/ui/country-flag";12import { FilterChips, type Chip } from "@/components/ui/filter-chips";13import { Pagination } from "@/components/ui/pagination";14import { SegmentedControl } from "@/components/ui/segmented";15import { EmptyState, OfflineNotice } from "@/components/ui/states";16import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";17import { api } from "@/lib/api";18import { fmtDate, fmtInt, fmtMw, fmtUsd, placeLine } from "@/lib/format";19import { fmtPartialDateSafe } from "@/lib/format-project";20import { STATUS_SHORT, isSiteScopeLabel } from "@/lib/labels";21import { routes } from "@/lib/routes";22import { pageMetadata } from "@/lib/seo";23import { spGet, spInt, type SearchParams } from "@/lib/sort";24import { ProjectFilters } from "../filters";25import { ProjectsMapView } from "../projects-map";26import { PROJECT_CLASS_OPTIONS, PROJECT_VIEWS, projectQueryString, type ProjectQuery, type ProjectView } from "../query";27import { ProjectsKanban, ProjectsTimeline, investmentCell } from "../views";2829export const revalidate = 120;3031export const metadata: Metadata = pageMetadata({32 title: "Data center projects & construction pipeline",33 description: "Announced, permitted, approved and under-construction data center projects worldwide — lifecycle stages, planned MW (published figures only), investment and expected opening. Table, map, timeline and kanban views.",34 path: routes.projects(),35});3637const PER_PAGE = 50;38const SORTS = ["updated", "mw", "announced", "opening"] as const;3940function parseQuery(sp: SearchParams): ProjectQuery {41 const q: ProjectQuery = {};42 const view = spGet(sp, "view");43 if (view && (PROJECT_VIEWS as readonly string[]).includes(view)) q.view = view as ProjectView;44 const s = spGet(sp, "status");45 if (s) q.status = s;46 const c = spGet(sp, "country");47 if (c) q.country = c.toUpperCase();48 const o = spGet(sp, "operator");49 if (o) q.operator = o;50 const mw = Number(spGet(sp, "min_mw"));51 if (Number.isFinite(mw) && mw > 0) q.min_mw = mw;52 const ai = spGet(sp, "ai");53 if (ai === "1" || ai === "true") q.ai = true;54 const cls = spGet(sp, "class");55 if (cls) q.project_class = cls;56 const ef = Number(spGet(sp, "expected_from"));57 if (Number.isFinite(ef) && ef > 1990) q.expected_from = ef;58 const et = Number(spGet(sp, "expected_to"));59 if (Number.isFinite(et) && et > 1990) q.expected_to = et;60 const text = spGet(sp, "q");61 if (text) q.q = text;62 const sort = spGet(sp, "sort");63 if (sort && (SORTS as readonly string[]).includes(sort)) q.sort = sort as ProjectQuery["sort"];64 const order = spGet(sp, "order");65 if (order === "asc" || order === "desc") q.order = order;66 q.page = spInt(sp, "page");67 return q;68}6970export default async function ProjectsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {71 const sp = await searchParams;72 const query = parseQuery(sp);73 const view: ProjectView = query.view ?? "table";74 const page = query.page ?? 1;75 const perPage = view === "table" ? PER_PAGE : 100;76 const [pipe, list, countriesRes] = await Promise.all([77 api.projectPipeline(),78 api.projects({ status: query.status, country: query.country, operator: query.operator, min_mw: query.min_mw, ai: query.ai, project_class: query.project_class, expected_from: query.expected_from, expected_to: query.expected_to, q: query.q, sort: query.sort ?? (view === "timeline" ? "opening" : "updated"), order: query.order ?? (view === "timeline" ? "asc" : undefined), page, per_page: perPage }),79 api.countries(),80 ]);81 const p = pipe.ok ? pipe.data : null;82 const countries = countriesRes.items.map((c) => ({ iso2: c.iso2, name: c.name }));83 const href = (patch: Partial<ProjectQuery>) => routes.projects(projectQueryString({ ...query, page: undefined, ...patch }));84 const sortHref = (key: (typeof SORTS)[number]) => {85 const active = (query.sort ?? "updated") === key;86 const next = active ? (query.order === "asc" ? "desc" : "asc") : key === "opening" || key === "announced" ? "asc" : "desc";87 return href({ sort: key, order: next });88 };89 const sortState = (key: (typeof SORTS)[number]) => ((query.sort ?? "updated") === key ? (query.order ?? (key === "opening" || key === "announced" ? "asc" : "desc")) : null);9091 const byStatus: Partial<Record<FacilityStatus, number>> = {};92 for (const s of p?.byStatus ?? []) byStatus[s.status] = (byStatus[s.status] ?? 0) + s.count;93 const byYear = (p?.byYear ?? []).map((y) => ({ year: y.year, count: y.count, mw: y.mw }));9495 const chips: Chip[] = [];96 for (const s of (query.status ?? "").split(",").filter(Boolean)) chips.push({ key: `status-${s}`, label: "Stage", value: STATUS_SHORT[s as FacilityStatus] ?? s, removeHref: href({ status: (query.status ?? "").split(",").filter((x) => x && x !== s).join(",") || undefined }) });97 if (query.country) chips.push({ key: "country", label: "Country", value: countries.find((c) => c.iso2 === query.country)?.name ?? query.country, removeHref: href({ country: undefined }) });98 if (query.operator) chips.push({ key: "operator", label: "Operator", value: query.operator, removeHref: href({ operator: undefined }) });99 if (query.min_mw) chips.push({ key: "min_mw", label: "Planned", value: `≥ ${fmtMw(query.min_mw)}`, removeHref: href({ min_mw: undefined }) });100 if (query.ai) chips.push({ key: "ai", label: "AI", value: "AI infrastructure", removeHref: href({ ai: undefined }) });101 for (const c of (query.project_class ?? "").split(",").filter(Boolean)) chips.push({ key: `class-${c}`, label: "Class", value: PROJECT_CLASS_OPTIONS.find((o) => o.v === c)?.label ?? c, removeHref: href({ project_class: (query.project_class ?? "").split(",").filter((x) => x && x !== c).join(",") || undefined }) });102 if (query.expected_from || query.expected_to) chips.push({ key: "expected", label: "Expected", value: `${query.expected_from ?? "…"}–${query.expected_to ?? "…"}`, removeHref: href({ expected_from: undefined, expected_to: undefined }) });103 if (query.q) chips.push({ key: "q", label: "Search", value: query.q, removeHref: href({ q: undefined }) });104105 const views: Array<{ value: ProjectView; label: React.ReactNode; title: string }> = [106 { value: "table", label: <><Table2 className="size-3.5" strokeWidth={1.75} /> Table</>, title: "Sortable table" },107 { value: "map", label: <><MapIcon className="size-3.5" strokeWidth={1.75} /> Map</>, title: "Located projects on the map" },108 { value: "timeline", label: <><Activity className="size-3.5" strokeWidth={1.75} /> Timeline</>, title: "By expected opening year" },109 { value: "kanban", label: <><Columns3 className="size-3.5" strokeWidth={1.75} /> Kanban</>, title: "Columns by lifecycle stage" },110 ];111112 return (113 <Page>114 <PageHeader115 eyebrow="Pipeline"116 title="Data center projects"117 description={p ? `${fmtInt(p.totals.projects)} projects tracked from rumour to opening. Planned MW are published, site-scoped figures (≈ never imputed); company-wide totals are kept as evidence, never summed.` : "Announced, permitted, approved and under-construction projects worldwide."}118 aside={<SegmentedControl label="View" value={view} options={views.map((v) => ({ value: v.value, label: v.label, title: v.title, href: href({ view: v.value === "table" ? undefined : v.value }) }))} />}119 />120121 {!pipe.ok && <OfflineNotice detail={pipe.error} className="mb-3" />}122123 {/* funnel */}124 <section aria-label="Construction pipeline funnel" className="border-y border-hair py-4">125 <PipelineFunnel data={{ byStatus: p?.byStatus ?? [], openedLast12m: null }} />126 </section>127128 <div className="grid gap-6 py-6 md:grid-cols-3">129 <div>130 <div className="label-caps mb-2">Investment announced</div>131 <div className="figure text-[22px] font-medium text-ink">{p?.totals.investmentUsd ? fmtUsd(p.totals.investmentUsd) : "—"}</div>132 <p className="text-[11.5px] text-ink-3">{p?.totals.investmentUsd ? "where disclosed, site-scoped only" : "not disclosed"} · {p ? `${fmtInt(p.totals.ai)} AI projects` : ""}</p>133 </div>134 <div>135 <div className="label-caps mb-2">By expected year</div>136 {byYear.length ? <PipelineByYear data={byYear} /> : <EmptyState compact title="No dated pipeline yet" description="Projects with a published expected opening will chart here." />}137 </div>138 <div>139 <div className="label-caps mb-2">Top countries</div>140 {p?.byCountry.length ? (141 <BarList items={p.byCountry.slice(0, 8).map((c) => ({ label: <CountryFlag iso2={c.countryIso2} withName />, value: c.count, href: href({ country: c.countryIso2 }), secondary: c.mw !== null ? `≈ ${fmtMw(c.mw)}` : undefined }))} />142 ) : (143 <EmptyState compact title="No located projects yet" description="Country appears once a project is geolocated." />144 )}145 </div>146 </div>147148 <div className="sticky top-12 z-[20] -mx-3 border-y border-hair bg-paper/95 px-3 py-2 backdrop-blur sm:top-14 sm:mx-0 sm:px-0">149 <Suspense>150 <ProjectFilters query={query} total={list.total} countries={countries} />151 </Suspense>152 </div>153 <FilterChips chips={chips} resetHref={routes.projects(view !== "table" ? `view=${view}` : undefined)} className="py-2" />154155 <div className="pt-1">156 {list.items.length === 0 ? (157 <EmptyState compact title={chips.length ? "No projects match these filters" : "No projects tracked yet"} description={chips.length ? "Loosen a filter or clear them all." : "Announced and under-construction projects appear once ingested."} />158 ) : view === "map" ? (159 <ProjectsMapView items={list.items} total={list.total} projectStatus={query.status} />160 ) : view === "timeline" ? (161 <ProjectsTimeline items={list.items} />162 ) : view === "kanban" ? (163 <ProjectsKanban items={list.items} counts={byStatus} />164 ) : (165 <Table>166 <THead>167 <tr>168 <Th>Project</Th>169 <Th className="hidden sm:table-cell">Operator</Th>170 <Th>Location</Th>171 <Th>Stage</Th>172 <Th numeric sort={sortState("mw")} sortHref={sortHref("mw")}>173 Planned MW174 </Th>175 <Th numeric className="hidden lg:table-cell">176 Investment177 </Th>178 <Th className="hidden lg:table-cell" sort={sortState("announced")} sortHref={sortHref("announced")}>179 Announced180 </Th>181 <Th sort={sortState("opening")} sortHref={sortHref("opening")}>182 Expected183 </Th>184 <Th className="hidden lg:table-cell">Confidence</Th>185 </tr>186 </THead>187 <TBody>188 {list.items.map((pr) => {189 const site = pr.capacityScope == null || isSiteScopeLabel(pr.capacityScope);190 return (191 <Tr key={pr.id}>192 <Td primary>193 <span className="inline-flex flex-wrap items-center gap-1.5">194 <RowLink href={routes.project(pr.slug)}>{pr.name}</RowLink>195 {pr.isAi && <AiMark evidence={pr.aiEvidence} />}196 </span>197 <div className="text-[11.5px] text-ink-3 sm:hidden">{pr.operator?.name ?? "Operator unknown"}</div>198 </Td>199 <Td label="Operator" className="hidden sm:table-cell">200 {pr.operator ? (201 <Link href={routes.operator(pr.operator.slug)} className="text-ink-2 hover:text-ink hover:underline">202 {pr.operator.name}203 </Link>204 ) : (205 <span className="text-ink-4">—</span>206 )}207 </Td>208 <Td label="Location">209 <span className="inline-flex max-w-[220px] items-center gap-1.5 text-ink-2">210 <CountryFlag iso2={pr.countryIso2} size={12} />211 <span className="truncate">{pr.metro ? <Link href={routes.market(pr.metro.slug)} className="hover:text-ink hover:underline">{pr.metro.name}</Link> : placeLine(pr)}</span>212 {pr.geoPrecision && pr.geoPrecision !== "exact" && pr.geoPrecision !== "parcel" && <PrecisionBadge precision={pr.geoPrecision} size="xs" className="hidden md:inline-flex" />}213 </span>214 </Td>215 <Td label="Stage">216 <StatusBadge status={pr.status} short size="xs" />217 </Td>218 <Td label="Planned MW" numeric lead>219 {pr.plannedMw === null ? (220 <span className="text-ink-4">—</span>221 ) : site ? (222 <span>223 <EstimateMark className="mr-0.5" />224 {fmtMw(pr.plannedMw)}225 </span>226 ) : (227 <span className="inline-flex items-center gap-1 text-ink-4" title={`${fmtMw(pr.plannedMw)} is a ${pr.capacityScope}-level figure — not this site's`}>228 — <ScopeBadge scope={pr.capacityScope} />229 </span>230 )}231 </Td>232 <Td label="Investment" numeric className="hidden lg:table-cell">233 {investmentCell(pr) === "—" ? <span className="text-ink-4">—</span> : investmentCell(pr)}234 </Td>235 <Td label="Announced" className="figure hidden text-[12px] text-ink-3 lg:table-cell">236 {fmtDate(pr.announcedOn)}237 </Td>238 <Td label="Expected" className="figure text-[12px]">239 {pr.expectedOpening ? fmtPartialDateSafe(pr.expectedOpening) : <span className="text-ink-4">TBD</span>}240 </Td>241 <Td label="Confidence" className="hidden lg:table-cell">242 <ConfidenceBadge level={pr.confidence} size="xs" />243 </Td>244 </Tr>245 );246 })}247 </TBody>248 </Table>249 )}250 </div>251 <Pagination page={page} perPage={perPage} total={list.total} hrefFor={(pg) => routes.projects(projectQueryString({ ...query, page: pg }))} label="projects" className="mt-3" />252 <p className="mt-6 text-[11.5px] text-ink-4">253 Projects are reconciled from operator press releases, planning filings and news through a lifecycle state machine; status changes are versioned and appear in the{" "}254 <Link href={routes.live("type=project_announced,project_status_changed,construction_started")} className="underline decoration-hair-2 hover:text-ink">255 live feed256 </Link>257 . Locations are shown at the precision published (a city-level ring is never a site).258 </p>259 </Page>260 );261}262