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%
7.7 KB · 134 lines tsx
Raw Blame History
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