SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
5.6 KB · 139 lines tsx
Raw Blame History
1"use client";23import type { ClaimDTO, HistoryPoint, ProvenanceDTO } from "@dci/core";4import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";5import { cn } from "@/components/ui/cn";6import { fieldLabel } from "@/lib/field-labels";7import { EvidenceDrawer } from "./evidence-drawer";89/* ------------------------------------------------------------------------------------------10   Evidence context: one provider per entity page holding claims + provenance + history; any figure11   wrapped in <Evidence field="…"> opens the drawer on that field. Server pages render the provider12   with serialisable props; the wrapper and the drawer are the only client pieces.13   ------------------------------------------------------------------------------------------ */1415export interface EvidenceData {16  entityName: string;17  entityKind: "facility" | "project" | "operator";18  claims: ClaimDTO[];19  provenance: ProvenanceDTO[];20  history: HistoryPoint[];21}2223export interface EvidenceTarget {24  field: string;25  fields: string[];26  label: string;27  display?: string;28  mode: "evidence" | "history";29}3031interface Ctx extends EvidenceData {32  open: (t: Omit<EvidenceTarget, "mode"> & { mode?: EvidenceTarget["mode"] }) => void;33  close: () => void;34  target: EvidenceTarget | null;35  hasEvidence: (fields: string[]) => boolean;36}3738const EvidenceCtx = createContext<Ctx | null>(null);3940/** Field name → every alias it may appear under in claims (predicates) and provenance (camel / snake). */41export function fieldAliases(field: string): string[] {42  const snake = field.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);43  const camel = field.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());44  const set = new Set<string>([field, snake, camel]);45  const preds = PREDICATES_BY_FIELD[camel] ?? PREDICATES_BY_FIELD[field];46  if (preds) for (const p of preds) set.add(p);47  return Array.from(set);48}4950/** Column ↔ claim predicates (mirrors CAPACITY_COLUMN in @dci/core, plus investment / status). */51export const PREDICATES_BY_FIELD: Record<string, string[]> = {52  itCapacityMw: ["it_capacity_mw", "critical_power_mw"],53  totalPowerMw: ["current_power_mw"],54  plannedPowerMw: ["planned_power_mw", "phase_mw"],55  plannedMw: ["planned_power_mw", "phase_mw"],56  utilityCapacityMw: ["utility_capacity_mw"],57  gridConnectionMw: ["grid_connection_mw"],58  ultimateCampusMw: ["ultimate_campus_mw"],59  investmentUsd: ["project_investment_usd", "campus_investment_usd", "company_investment_usd", "country_program_usd", "multi_year_capex_usd", "deal_value_usd"],60  status: ["status"],61};6263export function EvidenceProvider({ data, children }: { data: EvidenceData; children: ReactNode }) {64  const [target, setTarget] = useState<EvidenceTarget | null>(null);65  const open = useCallback((t: Omit<EvidenceTarget, "mode"> & { mode?: EvidenceTarget["mode"] }) => setTarget({ ...t, mode: t.mode ?? "evidence" }), []);66  const close = useCallback(() => setTarget(null), []);67  const index = useMemo(() => {68    const s = new Set<string>();69    for (const c of data.claims) s.add(c.predicate);70    for (const p of data.provenance) s.add(p.field);71    for (const h of data.history) {72      s.add(h.field);73      if (h.predicate) s.add(h.predicate);74    }75    return s;76  }, [data]);77  const hasEvidence = useCallback((fields: string[]) => fields.some((f) => fieldAliases(f).some((a) => index.has(a))), [index]);78  const value = useMemo<Ctx>(() => ({ ...data, open, close, target, hasEvidence }), [data, open, close, target, hasEvidence]);79  return (80    <EvidenceCtx.Provider value={value}>81      {children}82      <EvidenceDrawer />83    </EvidenceCtx.Provider>84  );85}8687export function useEvidence(): Ctx | null {88  return useContext(EvidenceCtx);89}9091export interface EvidenceProps {92  /** primary field (camelCase API name) */93  field: string;94  /** extra aliases (e.g. ["plannedMw", "planned_power_mw"]) */95  fields?: string[];96  /** label shown in the drawer header (defaults from the field) */97  label?: string;98  /** preformatted display of the current value for the drawer header */99  display?: string;100  /** open straight on the history list */101  history?: boolean;102  children: ReactNode;103  className?: string;104  /** render as block (metric cells) rather than inline */105  block?: boolean;106  /** show the wrapper even when no evidence exists (renders children plain, no button) */107  plainWhenEmpty?: boolean;108}109110/**111 * Click target around an important figure. Renders a real <button> (keyboard + screen-reader friendly) with a dotted112 * underline; when the page has no evidence for the field the children render untouched so nothing pretends to be113 * sourced.114 */115export function Evidence({ field, fields = [], label, display, history, children, className, block, plainWhenEmpty = true }: EvidenceProps) {116  const ctx = useEvidence();117  const all = useMemo(() => [field, ...fields], [field, fields]);118  if (!ctx) return <>{children}</>;119  const has = ctx.hasEvidence(all);120  if (!has && plainWhenEmpty) return <>{children}</>;121  const lbl = label ?? fieldLabel(field);122  return (123    <button124      type="button"125      onClick={() => ctx.open({ field, fields: all, label: lbl, display, mode: history ? "history" : "evidence" })}126      aria-label={`${history ? "History" : "Evidence"} for ${lbl}`}127      aria-haspopup="dialog"128      className={cn(129        "group/ev rounded-xs text-left decoration-hair-2 decoration-dotted underline-offset-[3px] hover:decoration-accent hover:underline focus-visible:outline-2 focus-visible:outline-accent",130        block ? "block w-full" : "inline-flex items-baseline gap-1 max-sm:my-[-9px] max-sm:py-[9px]",131        className,132      )}133      data-evidence={field}134    >135      {children}136    </button>137  );138}139