"use client"; import type { ClaimDTO, HistoryPoint, ProvenanceDTO } from "@dci/core"; import { ArrowRight, ExternalLink, History as HistoryIcon, Quote } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; import { ConfidenceBadge, EstimateMark, ScopeBadge, SemanticsLabel, TierMark } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { Sheet } from "@/components/ui/sheet"; import { SourceBadge } from "@/components/ui/source-badge"; import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs"; import { fieldLabel, valueHint } from "@/lib/field-labels"; import { fmtDate, fmtValue } from "@/lib/format"; import { CLAIM_STATUS_LABEL, CONFIDENCE_LABEL, EXTRACTION_LABEL, extractionKind, isSiteScopeLabel } from "@/lib/labels"; import { routes } from "@/lib/routes"; import { fieldAliases, useEvidence } from "./evidence-context"; /* ------------------------------------------------------------------------------------------ Evidence drawer (§24, §80): right-side sheet on desktop, bottom sheet on mobile. Shows every claim + provenance observation behind one field with source, dates, confidence, authority tier, extraction method and the supporting sentence; the HISTORY tab lists dated value changes with old → new, the source and a WHY? toggle revealing the evidence behind each change. ------------------------------------------------------------------------------------------ */ function hostOf(url: string | null | undefined): string { if (!url) return ""; try { return new URL(url).host.replace(/^www\./, ""); } catch { return url; } } /** Highlight the figure inside the supporting sentence (first numeric token matching the value, else nothing). */ function Quoted({ text, value }: { text: string; value: number | string | null }) { const parts = useMemo(() => { if (value === null || value === undefined) return [text]; const v = typeof value === "number" ? value : Number(String(value).replace(/[^\d.]/g, "")); if (!Number.isFinite(v)) return [text]; const variants = new Set([String(v), v.toLocaleString("en-US"), v.toFixed(1), v.toFixed(0), (v / 1000).toString(), (v / 1e6).toString(), (v / 1e9).toString()]); for (const cand of Array.from(variants).sort((a, b) => b.length - a.length)) { const i = text.indexOf(cand); if (i >= 0 && cand.length > 0) return [text.slice(0, i), text.slice(i, i + cand.length), text.slice(i + cand.length)]; } return [text]; }, [text, value]); return (
{parts.length === 3 ? ( <> {parts[0]} {parts[1]} {parts[2]} ) : ( parts[0] )}
); } function Row({ k, v }: { k: string; v: ReactNode }) { return (
{k}
{v}
); } function ClaimCard({ c, hint }: { c: ClaimDTO; hint: ReturnType }) { const method = extractionKind(c.parserName); const nonSite = !isSiteScopeLabel(c.scope); return (
{c.isWinner && shown} {c.status !== "current" && {CLAIM_STATUS_LABEL[c.status]}}
{c.isEstimate && } {c.value !== null ? fmtValue(c.value, hint ?? (c.unit === "MW" ? "mw" : c.unit === "USD" ? "usd" : undefined)) : (c.valueText ?? "—")} {c.unit && c.value !== null && !/MW|USD/.test(c.unit) && {c.unit}}
{nonSite &&

Not assigned to this site: the sentence describes a {c.scope === "unknown" ? "figure of unknown scope" : `${c.scope}-level figure`}{c.scopeReason ? ` (${c.scopeReason})` : ""}. Stored as evidence, never summed.

} {c.evidenceText && (
)}
{hostOf(c.url)} } /> {c.publishedAt ? fmtDate(c.publishedAt) : "not stated"}} /> {fmtDate(c.retrievedAt)}} /> } /> for this field} /> {EXTRACTION_LABEL[method]}{c.parserName && {c.parserName}{c.parserVersion ? ` v${c.parserVersion}` : ""}}} /> {c.rejectionReason && {c.rejectionReason}} />}
{c.documentId && ( )}
); } function ProvenanceCard({ p, hint }: { p: ProvenanceDTO; hint: ReturnType }) { const method = extractionKind(p.method); return (
{p.scope && } {p.isWinner && shown}
{p.isEstimate && } {fmtValue(p.value, hint)}
{p.sourceName} · {hostOf(p.url)}} /> {fmtDate(p.firstObserved)}} /> {fmtDate(p.lastObserved)}} /> {fmtDate(p.retrievedAt)}} /> } /> {EXTRACTION_LABEL[method]}{p.method && {p.method}}} />
); } function HistoryList({ points, claims, hint }: { points: HistoryPoint[]; claims: ClaimDTO[]; hint: ReturnType }) { const [why, setWhy] = useState(null); if (!points.length) return

No dated history for this field yet — the value has been observed once. Every future change (old → new, source, evidence) will be listed here.

; return (
    {points.map((h, i) => { const claim = h.claimId ? claims.find((c) => c.id === h.claimId) : undefined; const open = why === i; return (
  1. {h.kind}
    {h.oldValue !== undefined && h.oldValue !== null && ( <> {fmtValue(h.oldValue, hint)} )} {fmtValue(h.value, hint)}
    {h.sourceKind && } {h.sourceName && {h.sourceName}} {h.url && ( source )}
    {open && (
    {claim ? ( ) : (
    {h.kind === "changed" ? "Detected as a change between two observations of the same source page." : h.kind === "claim" ? "Recorded as a claim from the document linked above." : "Observed value on the linked source page (no sentence-level evidence recorded for this observation)."} {h.url && (
    )}
    )}
    )}
  2. ); })}
); } export function EvidenceDrawer() { const ctx = useEvidence(); const t = ctx?.target ?? null; const [tab, setTab] = useState("evidence"); const [lastTarget, setLastTarget] = useState(null); if (t !== lastTarget) { setLastTarget(t); if (t) setTab(t.mode); } const aliases = useMemo(() => new Set(t ? t.fields.flatMap(fieldAliases) : []), [t]); const claims = useMemo(() => (ctx && t ? ctx.claims.filter((c) => aliases.has(c.predicate)).sort((a, b) => Number(b.isWinner) - Number(a.isWinner) || (a.status === "current" ? -1 : 1)) : []), [ctx, t, aliases]); const prov = useMemo(() => (ctx && t ? ctx.provenance.filter((p) => aliases.has(p.field)).sort((a, b) => Number(!!b.isWinner) - Number(!!a.isWinner) || (a.lastObserved < b.lastObserved ? 1 : -1)) : []), [ctx, t, aliases]); const history = useMemo(() => (ctx && t ? ctx.history.filter((h) => aliases.has(h.field) || (h.predicate ? aliases.has(h.predicate) : false)).sort((a, b) => (a.date < b.date ? 1 : -1)) : []), [ctx, t, aliases]); if (!ctx) return null; const hint = t ? valueHint(t.field) : undefined; const winner = claims.find((c) => c.isWinner) ?? null; const winnerProv = prov.find((p) => p.isWinner) ?? prov[0] ?? null; const display = t?.display ?? (winner ? fmtValue(winner.value ?? winner.valueText, hint) : winnerProv ? fmtValue(winnerProv.value, hint) : "—"); const sources = new Set([...claims.map((c) => c.sourceId), ...prov.map((p) => p.sourceId)]).size; return ( {t && (
{display}
{winner ? ( <> ) : winnerProv?.scope ? ( ) : null} {(winner ?? winnerProv) && }
{display === "—" &&

Not published by any indexed source. Nothing is imputed.

}
Evidence History {claims.length === 0 && prov.length === 0 ? (

No source has published this field yet. When a document does, its sentence, date and authority tier appear here.

) : (
{claims.map((c) => ( ))} {prov.length > 0 && claims.length > 0 &&
Other observations
} {prov.map((p, i) => ( ))}
)}

{(winner ?? winnerProv) ? CONFIDENCE_LABEL[(winner ?? winnerProv)!.confidence] : "Confidence"} is computed from the source kind, the number of independent primary sources and the extraction method.{" "} Methodology

)}
); }