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%
16.9 KB · 294 lines tsx
Raw Blame History
1"use client";23import type { ClaimDTO, HistoryPoint, ProvenanceDTO } from "@dci/core";4import { ArrowRight, ExternalLink, History as HistoryIcon, Quote } from "lucide-react";5import { useMemo, useState, type ReactNode } from "react";6import { ConfidenceBadge, EstimateMark, ScopeBadge, SemanticsLabel, TierMark } from "@/components/ui/badge";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { Sheet } from "@/components/ui/sheet";10import { SourceBadge } from "@/components/ui/source-badge";11import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs";12import { fieldLabel, valueHint } from "@/lib/field-labels";13import { fmtDate, fmtValue } from "@/lib/format";14import { CLAIM_STATUS_LABEL, CONFIDENCE_LABEL, EXTRACTION_LABEL, extractionKind, isSiteScopeLabel } from "@/lib/labels";15import { routes } from "@/lib/routes";16import { fieldAliases, useEvidence } from "./evidence-context";1718/* ------------------------------------------------------------------------------------------19   Evidence drawer (§24, §80): right-side sheet on desktop, bottom sheet on mobile. Shows every20   claim + provenance observation behind one field with source, dates, confidence, authority tier,21   extraction method and the supporting sentence; the HISTORY tab lists dated value changes with22   old → new, the source and a WHY? toggle revealing the evidence behind each change.23   ------------------------------------------------------------------------------------------ */2425function hostOf(url: string | null | undefined): string {26  if (!url) return "";27  try {28    return new URL(url).host.replace(/^www\./, "");29  } catch {30    return url;31  }32}3334/** Highlight the figure inside the supporting sentence (first numeric token matching the value, else nothing). */35function Quoted({ text, value }: { text: string; value: number | string | null }) {36  const parts = useMemo(() => {37    if (value === null || value === undefined) return [text];38    const v = typeof value === "number" ? value : Number(String(value).replace(/[^\d.]/g, ""));39    if (!Number.isFinite(v)) return [text];40    const variants = new Set<string>([String(v), v.toLocaleString("en-US"), v.toFixed(1), v.toFixed(0), (v / 1000).toString(), (v / 1e6).toString(), (v / 1e9).toString()]);41    for (const cand of Array.from(variants).sort((a, b) => b.length - a.length)) {42      const i = text.indexOf(cand);43      if (i >= 0 && cand.length > 0) return [text.slice(0, i), text.slice(i, i + cand.length), text.slice(i + cand.length)];44    }45    return [text];46  }, [text, value]);47  return (48    <blockquote className="relative rounded-sm border border-hair bg-surface-2/60 px-3 py-2 text-[12.5px] leading-relaxed text-ink-2">49      <Quote className="absolute right-2 top-2 size-3 text-ink-4" aria-hidden />50      {parts.length === 3 ? (51        <>52          {parts[0]}53          <mark className="rounded-[1px] bg-accent/20 px-0.5 text-ink">{parts[1]}</mark>54          {parts[2]}55        </>56      ) : (57        parts[0]58      )}59    </blockquote>60  );61}6263function Row({ k, v }: { k: string; v: ReactNode }) {64  return (65    <div className="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 border-b border-hair py-1.5 text-[12.5px] last:border-b-0">66      <dt className="text-ink-3">{k}</dt>67      <dd className="min-w-0 break-words text-ink">{v}</dd>68    </div>69  );70}7172function ClaimCard({ c, hint }: { c: ClaimDTO; hint: ReturnType<typeof valueHint> }) {73  const method = extractionKind(c.parserName);74  const nonSite = !isSiteScopeLabel(c.scope);75  return (76    <article className={cn("rounded-sm border px-3 py-2.5", c.isWinner ? "border-accent/40" : "border-hair", c.status !== "current" && "opacity-80")} aria-label={`${c.label} claim from ${c.sourceName}`}>77      <div className="flex flex-wrap items-center gap-1.5">78        <SourceBadge kind={c.sourceKind} name={c.sourceName} url={c.url} publishedAt={c.publishedAt} retrievedAt={c.retrievedAt} documentId={c.documentId} />79        <ScopeBadge scope={c.scope} />80        <SemanticsLabel semantics={c.predicate} />81        <span className="ml-auto inline-flex items-center gap-1.5">82          {c.isWinner && <span className="inline-flex h-[18px] items-center rounded-xs border border-accent/40 bg-accent-soft px-1.5 text-[10px] font-medium uppercase tracking-[0.06em] text-accent-ink">shown</span>}83          {c.status !== "current" && <span className="inline-flex h-[18px] items-center rounded-xs border border-hair-2 px-1.5 text-[10px] uppercase tracking-[0.06em] text-ink-3">{CLAIM_STATUS_LABEL[c.status]}</span>}84        </span>85      </div>86      <div className="mt-2 flex items-baseline gap-2">87        <span className="figure text-[18px] font-medium text-ink">88          {c.isEstimate && <EstimateMark className="mr-0.5 text-[13px]" />}89          {c.value !== null ? fmtValue(c.value, hint ?? (c.unit === "MW" ? "mw" : c.unit === "USD" ? "usd" : undefined)) : (c.valueText ?? "—")}90        </span>91        {c.unit && c.value !== null && !/MW|USD/.test(c.unit) && <span className="text-[12px] text-ink-3">{c.unit}</span>}92      </div>93      {nonSite && <p className="mt-1 text-[11.5px] text-ink-3">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.</p>}94      {c.evidenceText && (95        <div className="mt-2">96          <Quoted text={c.evidenceText} value={c.value} />97        </div>98      )}99      <dl className="mt-2">100        <Row k="Document" v={<a href={c.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink hover:text-accent">{hostOf(c.url)} <ExternalLink className="size-3" /></a>} />101        <Row k="Published" v={<span className="figure">{c.publishedAt ? fmtDate(c.publishedAt) : "not stated"}</span>} />102        <Row k="Retrieved" v={<span className="figure">{fmtDate(c.retrievedAt)}</span>} />103        <Row k="Confidence" v={<ConfidenceBadge level={c.confidence} short={false} size="xs" />} />104        <Row k="Authority" v={<span className="inline-flex items-center gap-1.5"><TierMark tier={c.authorityTier} /> <span className="text-[11.5px] text-ink-3">for this field</span></span>} />105        <Row k="Extraction" v={<span>{EXTRACTION_LABEL[method]}{c.parserName && <span className="figure ml-1.5 text-[11px] text-ink-3">{c.parserName}{c.parserVersion ? ` v${c.parserVersion}` : ""}</span>}</span>} />106        {c.rejectionReason && <Row k="Reason" v={<span className="text-ink-2">{c.rejectionReason}</span>} />}107      </dl>108      <div className="mt-2 flex items-center gap-2">109        <Button href={c.url} external size="xs" variant="secondary">110          View source <ExternalLink className="size-3" />111        </Button>112        {c.documentId && (113          <Button href={`/admin/documents/${encodeURIComponent(c.documentId)}`} size="xs" variant="ghost" className="text-ink-3">114            Archived document115          </Button>116        )}117      </div>118    </article>119  );120}121122function ProvenanceCard({ p, hint }: { p: ProvenanceDTO; hint: ReturnType<typeof valueHint> }) {123  const method = extractionKind(p.method);124  return (125    <article className={cn("rounded-sm border px-3 py-2.5", p.isWinner ? "border-accent/40" : "border-hair")} aria-label={`${fieldLabel(p.field)} observed on ${p.sourceName}`}>126      <div className="flex flex-wrap items-center gap-1.5">127        <SourceBadge kind={p.sourceKind} name={p.sourceName} url={p.url} retrievedAt={p.retrievedAt} documentId={p.documentId} />128        {p.scope && <ScopeBadge scope={p.scope} />}129        {p.isWinner && <span className="ml-auto inline-flex h-[18px] items-center rounded-xs border border-accent/40 bg-accent-soft px-1.5 text-[10px] font-medium uppercase tracking-[0.06em] text-accent-ink">shown</span>}130      </div>131      <div className="mt-2 figure text-[16px] font-medium text-ink [overflow-wrap:anywhere]">132        {p.isEstimate && <EstimateMark className="mr-0.5 text-[12px]" />}133        {fmtValue(p.value, hint)}134      </div>135      <dl className="mt-2">136        <Row k="Source" v={<span>{p.sourceName} <span className="text-ink-3">· {hostOf(p.url)}</span></span>} />137        <Row k="First observed" v={<span className="figure">{fmtDate(p.firstObserved)}</span>} />138        <Row k="Last observed" v={<span className="figure">{fmtDate(p.lastObserved)}</span>} />139        <Row k="Retrieved" v={<span className="figure">{fmtDate(p.retrievedAt)}</span>} />140        <Row k="Confidence" v={<ConfidenceBadge level={p.confidence} short={false} size="xs" />} />141        <Row k="Extraction" v={<span>{EXTRACTION_LABEL[method]}{p.method && <span className="figure ml-1.5 text-[11px] text-ink-3">{p.method}</span>}</span>} />142      </dl>143      <div className="mt-2">144        <Button href={p.url} external size="xs" variant="secondary">145          View source <ExternalLink className="size-3" />146        </Button>147      </div>148    </article>149  );150}151152function HistoryList({ points, claims, hint }: { points: HistoryPoint[]; claims: ClaimDTO[]; hint: ReturnType<typeof valueHint> }) {153  const [why, setWhy] = useState<number | null>(null);154  if (!points.length) return <p className="rounded-sm border border-dashed border-hair-2 px-3 py-3 text-[12.5px] text-ink-3">No dated history for this field yet — the value has been observed once. Every future change (old → new, source, evidence) will be listed here.</p>;155  return (156    <ol className="relative ml-1.5 border-l border-hair">157      {points.map((h, i) => {158        const claim = h.claimId ? claims.find((c) => c.id === h.claimId) : undefined;159        const open = why === i;160        return (161          <li key={`${h.date}-${i}`} className="relative pb-3 pl-4 last:pb-0">162            <span aria-hidden className={cn("absolute -left-[4.5px] top-[6px] size-2 rounded-full ring-2 ring-surface", h.kind === "changed" ? "bg-accent" : h.kind === "claim" ? "bg-ink-3" : "bg-ink-4")} />163            <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">164              <time className="figure text-[11.5px] text-ink-3" dateTime={h.date}>165                {fmtDate(h.date)}166              </time>167              <span className="text-[11px] uppercase tracking-[0.06em] text-ink-4">{h.kind}</span>168            </div>169            <div className="figure mt-0.5 inline-flex flex-wrap items-center gap-1.5 text-[13px]">170              {h.oldValue !== undefined && h.oldValue !== null && (171                <>172                  <span className="text-ink-3 line-through decoration-ink-4">{fmtValue(h.oldValue, hint)}</span>173                  <ArrowRight className="size-3 text-ink-4" aria-label="changed to" />174                </>175              )}176              <span className="font-medium text-ink">{fmtValue(h.value, hint)}</span>177            </div>178            <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3">179              {h.sourceKind && <SourceBadge kind={h.sourceKind} name={h.sourceName} url={h.url} />}180              {h.sourceName && <span>{h.sourceName}</span>}181              {h.url && (182                <a href={h.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent">183                  source <ExternalLink className="size-3" />184                </a>185              )}186              <button type="button" onClick={() => setWhy(open ? null : i)} aria-expanded={open} className="ml-auto inline-flex h-7 items-center rounded-xs border border-hair-2 px-1.5 text-[10.5px] font-medium uppercase tracking-[0.06em] text-ink-2 hover:border-ink hover:text-ink">187                Why?188              </button>189            </div>190            {open && (191              <div className="mt-2 animate-fade-in">192                {claim ? (193                  <ClaimCard c={claim} hint={hint} />194                ) : (195                  <div className="rounded-sm border border-hair px-3 py-2 text-[12.5px] text-ink-2">196                    {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)."}197                    {h.url && (198                      <div className="mt-1.5">199                        <Button href={h.url} external size="xs" variant="secondary">200                          View source <ExternalLink className="size-3" />201                        </Button>202                      </div>203                    )}204                  </div>205                )}206              </div>207            )}208          </li>209        );210      })}211    </ol>212  );213}214215export function EvidenceDrawer() {216  const ctx = useEvidence();217  const t = ctx?.target ?? null;218  const [tab, setTab] = useState<string>("evidence");219  const [lastTarget, setLastTarget] = useState<typeof t>(null);220  if (t !== lastTarget) {221    setLastTarget(t);222    if (t) setTab(t.mode);223  }224  const aliases = useMemo(() => new Set(t ? t.fields.flatMap(fieldAliases) : []), [t]);225  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]);226  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]);227  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]);228  if (!ctx) return null;229  const hint = t ? valueHint(t.field) : undefined;230  const winner = claims.find((c) => c.isWinner) ?? null;231  const winnerProv = prov.find((p) => p.isWinner) ?? prov[0] ?? null;232  const display = t?.display ?? (winner ? fmtValue(winner.value ?? winner.valueText, hint) : winnerProv ? fmtValue(winnerProv.value, hint) : "—");233  const sources = new Set([...claims.map((c) => c.sourceId), ...prov.map((p) => p.sourceId)]).size;234235  return (236    <Sheet open={!!t} onClose={ctx.close} title={t ? `${t.label}` : undefined} description={t ? `${ctx.entityName} · ${sources} ${sources === 1 ? "source" : "sources"}` : undefined} width={440} heightVh={78} side="right">237      {t && (238        <div className="flex flex-col gap-3">239          <div className="flex flex-wrap items-end gap-x-3 gap-y-1 border-b border-hair pb-3">240            <div className="figure text-[26px] font-medium leading-none tracking-[-0.02em] text-ink">{display}</div>241            <div className="flex flex-wrap items-center gap-1.5 pb-0.5">242              {winner ? (243                <>244                  <SemanticsLabel semantics={winner.predicate} />245                  <ScopeBadge scope={winner.scope} />246                </>247              ) : winnerProv?.scope ? (248                <ScopeBadge scope={winnerProv.scope} />249              ) : null}250              {(winner ?? winnerProv) && <ConfidenceBadge level={(winner ?? winnerProv)!.confidence} size="xs" />}251            </div>252            {display === "—" && <p className="w-full text-[12px] text-ink-3">Not published by any indexed source. Nothing is imputed.</p>}253          </div>254255          <Tabs value={tab} onValueChange={setTab}>256            <TabList label="Evidence views">257              <Tab value="evidence" count={claims.length + prov.length}>258                Evidence259              </Tab>260              <Tab value="history" count={history.length}>261                <HistoryIcon className="size-3.5" aria-hidden /> History262              </Tab>263            </TabList>264            <TabPanel value="evidence">265              {claims.length === 0 && prov.length === 0 ? (266                <p className="rounded-sm border border-dashed border-hair-2 px-3 py-3 text-[12.5px] text-ink-3">No source has published this field yet. When a document does, its sentence, date and authority tier appear here.</p>267              ) : (268                <div className="flex flex-col gap-2.5">269                  {claims.map((c) => (270                    <ClaimCard key={c.id} c={c} hint={hint} />271                  ))}272                  {prov.length > 0 && claims.length > 0 && <div className="label-caps pt-1">Other observations</div>}273                  {prov.map((p, i) => (274                    <ProvenanceCard key={`${p.sourceId}-${p.field}-${i}`} p={p} hint={hint} />275                  ))}276                </div>277              )}278              <p className="mt-3 text-[11px] text-ink-4">279                {(winner ?? winnerProv) ? CONFIDENCE_LABEL[(winner ?? winnerProv)!.confidence] : "Confidence"} is computed from the source kind, the number of independent primary sources and the extraction method.{" "}280                <a href={`${routes.methodology()}#confidence`} className="underline decoration-hair-2 hover:text-ink">281                  Methodology282                </a>283              </p>284            </TabPanel>285            <TabPanel value="history">286              <HistoryList points={history} claims={ctx.claims} hint={hint} />287            </TabPanel>288          </Tabs>289        </div>290      )}291    </Sheet>292  );293}294