SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.2 KB · 96 lines tsx
Raw Blame History
1import type { ProjectDetail, ProjectStageDTO } from "@dci/core";2import { ExternalLink } from "lucide-react";3import { StatusDot } from "@/components/ui/status-dot";4import { cn } from "@/components/ui/cn";5import { fmtInt } from "@/lib/format";6import { fmtPartialDateSafe } from "@/lib/format-project";7import { STAGE_LABEL } from "@/lib/labels";89const MAIN: ProjectStageDTO["stage"][] = ["rumored", "proposed", "announced", "permitting", "approved", "under_construction", "partially_operational", "operational"];10const SIDE: ProjectStageDTO["stage"][] = ["delayed", "cancelled"];1112/**13 * Lifecycle strip (§32): one cell per stage in funnel order — reached stages filled, the current one accented,14 * unreached ones hollow; dates and the evidence link (source that moved the project there) under each.15 * Delayed / cancelled are side branches shown only when reached.16 */17export function ProjectStages({ stages }: { stages: ProjectStageDTO[] }) {18  const by = new Map(stages.map((s) => [s.stage, s]));19  const main = MAIN.map((k) => by.get(k)).filter((s): s is ProjectStageDTO => !!s);20  const side = SIDE.map((k) => by.get(k)).filter((s): s is ProjectStageDTO => !!s && s.reached);21  if (!main.length) return <p className="text-[12.5px] text-ink-3">No lifecycle stage recorded.</p>;22  return (23    <div className="flex flex-col gap-3">24      <ol className="-mx-3 flex overflow-x-auto px-3 no-scrollbar sm:mx-0 sm:px-0" aria-label="Lifecycle stages">25        {main.map((s, i) => (26          <li key={s.stage} className={cn("relative min-w-[112px] flex-1 border-t-2 pr-2 pt-2", s.current ? "border-accent" : s.reached ? "border-ink" : "border-hair-2")}>27            <span aria-hidden className={cn("absolute -top-[5px] left-0 size-2 rounded-full ring-2 ring-paper", s.current ? "bg-accent" : s.reached ? "bg-ink" : "border border-hair-2 bg-paper")} />28            <div className={cn("text-[11.5px] font-medium uppercase tracking-[0.06em]", s.current ? "text-accent-ink" : s.reached ? "text-ink" : "text-ink-4")}>{STAGE_LABEL[s.stage]}</div>29            <div className="figure mt-0.5 text-[12px] text-ink-2">{s.date ? fmtPartialDateSafe(s.date) : s.reached ? <span className="text-ink-4">date not published</span> : <span className="text-ink-4">—</span>}</div>30            {s.reached && (s.url || s.sourceName) && (31              <div className="mt-0.5 truncate text-[11px] text-ink-3">32                {s.url ? (33                  <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex max-w-full items-center gap-1 hover:text-accent">34                    <span className="truncate">{s.sourceName ?? "evidence"}</span> <ExternalLink className="size-3 shrink-0" />35                  </a>36                ) : (37                  s.sourceName38                )}39              </div>40            )}41            {i === main.length - 1 && null}42          </li>43        ))}44      </ol>45      {side.length > 0 && (46        <ul className="flex flex-wrap gap-2">47          {side.map((s) => (48            <li key={s.stage} className="inline-flex items-center gap-1.5 rounded-xs border border-hair px-2 py-1 text-[12px]">49              <StatusDot status={s.stage} size={7} />50              <span className={cn("font-medium", s.current && "text-ink")}>{STAGE_LABEL[s.stage]}</span>51              <span className="figure text-ink-3">{s.date ? fmtPartialDateSafe(s.date) : "date not published"}</span>52              {s.url && (53                <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent">54                  {s.sourceName ?? "source"} <ExternalLink className="size-3" />55                </a>56              )}57            </li>58          ))}59        </ul>60      )}61    </div>62  );63}6465const VELOCITY: Array<{ key: keyof ProjectDetail["velocityDays"]; label: string }> = [66  { key: "announcedToPermitting", label: "Announced → permitting" },67  { key: "permittingToApproval", label: "Permitting → approval" },68  { key: "approvalToConstruction", label: "Approval → construction" },69  { key: "constructionToOpening", label: "Construction → opening" },70  { key: "announcedToConstruction", label: "Announced → construction" },71];7273/** Days between reached stages; "—" when either date is unknown (never estimated). */74export function ProjectVelocity({ v }: { v: ProjectDetail["velocityDays"] }) {75  const any = VELOCITY.some((x) => v[x.key] !== null);76  return (77    <div>78      <div className="grid grid-cols-2 gap-x-3 gap-y-3 sm:grid-cols-5">79        {VELOCITY.map((x) => {80          const d = v[x.key];81          return (82            <div key={x.key} className="min-w-0">83              <div className={cn("figure text-[20px] font-medium leading-none tracking-[-0.02em]", d === null ? "text-ink-4" : "text-ink")}>84                {d === null ? "—" : fmtInt(d)}85                {d !== null && <span className="ml-1 text-[11px] font-medium text-ink-3">days</span>}86              </div>87              <div className="mt-1 text-[11px] leading-tight text-ink-3">{x.label}</div>88            </div>89          );90        })}91      </div>92      {!any && <p className="mt-2 text-[11.5px] text-ink-4">Velocity needs two dated stages; none of the stage transitions has published dates yet.</p>}93    </div>94  );95}96