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%
4.5 KB · 76 lines tsx
Raw Blame History
1import type { FacilityStatus } from "@dci/core";2import Link from "next/link";3import { cn } from "@/components/ui/cn";4import { StatusDot } from "@/components/ui/status-dot";5import { fmtInt, splitMw } from "@/lib/format";6import { routes } from "@/lib/routes";78export interface FunnelInput {9  /** project counts + published MW by status */10  byStatus: Array<{ status: FacilityStatus; count: number; mw: number | null }>;11  /** facilities opened in the last 12 months (from a pulse / velocity window) — null when the API does not expose it */12  openedLast12m?: { count: number; mw: number | null } | null;13}1415const STEPS: Array<{ key: string; label: string; statuses: FacilityStatus[]; tone: FacilityStatus }> = [16  { key: "announced", label: "Announced", statuses: ["rumored", "proposed", "announced", "expansion"], tone: "announced" },17  { key: "permitting", label: "Permitting", statuses: ["permitting"], tone: "permitting" },18  { key: "approved", label: "Approved", statuses: ["approved"], tone: "approved" },19  { key: "construction", label: "Construction", statuses: ["under_construction", "partially_operational"], tone: "under_construction" },20];2122/**23 * Construction pipeline funnel (§33): ANNOUNCED → PERMITTING → APPROVED → CONSTRUCTION → OPENED, each with project24 * count and the sum of published MW (GW when ≥ 1 000). Bars are proportional to the count; MW are labelled25 * "known published MW only" because most projects do not publish a figure.26 */27export function PipelineFunnel({ data, className, compact }: { data: FunnelInput; className?: string; compact?: boolean }) {28  const agg = STEPS.map((s) => {29    const rows = data.byStatus.filter((r) => s.statuses.includes(r.status));30    return { ...s, count: rows.reduce((a, r) => a + r.count, 0), mw: rows.some((r) => r.mw !== null) ? rows.reduce((a, r) => a + (r.mw ?? 0), 0) : null };31  });32  const opened = data.openedLast12m ?? null;33  const delayed = data.byStatus.filter((r) => r.status === "delayed" || r.status === "cancelled");34  const max = Math.max(1, ...agg.map((a) => a.count), opened?.count ?? 0);35  const cells = [...agg.map((a) => ({ key: a.key, label: a.label, count: a.count, mw: a.mw, tone: a.tone as FacilityStatus, href: routes.projects(`status=${a.statuses.join(",")}`) })), { key: "opened", label: "Opened · 12 m", count: opened?.count ?? null, mw: opened?.mw ?? null, tone: "operational" as FacilityStatus, href: routes.datacenters("status=operational&sort=opened") }];36  return (37    <div className={className}>38      <ol className={cn("grid gap-x-3 gap-y-3", compact ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5" : "grid-cols-2 sm:grid-cols-5")}>39        {cells.map((c) => {40          const s = splitMw(c.mw);41          const pct = c.count === null ? 0 : Math.max(2, (c.count / max) * 100);42          return (43            <li key={c.key} className="min-w-0">44              <Link href={c.href} className="group block">45                <div className="label-caps mb-1 flex min-w-0 items-center gap-1.5">46                  <StatusDot status={c.tone} size={7} /> <span className="truncate">{c.label}</span>47                </div>48                <div className={cn("figure font-medium leading-none tracking-[-0.02em] text-ink group-hover:text-accent", compact ? "text-[18px]" : "text-[22px] sm:text-[26px]")}>{c.count === null ? <span className="text-ink-4">—</span> : fmtInt(c.count)}</div>49                <div className="mt-1.5 h-[3px] w-full bg-surface-3">50                  <div className="h-full" style={{ width: `${pct}%`, background: `var(--st-${c.tone === "under_construction" ? "construction" : c.tone === "announced" ? "planned" : c.tone === "approved" || c.tone === "permitting" ? "permitting" : "operational"})` }} />51                </div>52                <div className="figure mt-1 text-[11.5px] text-ink-3">{c.count === null ? "not tracked as projects" : s ? `${s.value} ${s.unit}` : c.count > 0 ? "no MW published" : "—"}</div>53              </Link>54            </li>55          );56        })}57      </ol>58      <div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[11px] text-ink-4">59        <span className="label-caps text-ink-4">Known published MW only</span>60        {delayed.length > 0 && (61          <span>62            {delayed.map((d, i) => (63              <span key={d.status}>64                {i > 0 && " · "}65                <Link href={routes.projects(`status=${d.status}`)} className="hover:text-ink">66                  {fmtInt(d.count)} {d.status}67                </Link>68              </span>69            ))}70          </span>71        )}72      </div>73    </div>74  );75}76