SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
15.4 KB · 262 lines tsx
Raw Blame History
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