spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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