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.0 KB · 100 lines tsx
Raw Blame History
1import type { ProvenanceDTO } from "@dci/core";2import { ExternalLink } from "lucide-react";3import type { ReactNode } from "react";4import { ConfidenceBadge, EstimateMark, ScopeBadge } from "@/components/ui/badge";5import { SourceBadge } from "@/components/ui/source-badge";6import { cn } from "@/components/ui/cn";7import { ProvenancePopover } from "@/components/ui/popover";8import { EmptyState } from "@/components/ui/states";9import { TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";10import { fieldLabel, valueHint } from "@/lib/field-labels";11import { fmtDate, fmtValue } from "@/lib/format";1213/** Provenance rows backing one or more fields (API field names are camelCase; snake_case tolerated). */14export function provFor(items: ProvenanceDTO[] | undefined, ...fields: string[]): ProvenanceDTO[] {15  if (!items?.length) return [];16  const want = new Set(fields.flatMap((f) => [f, f.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`), f.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())]));17  return items.filter((p) => want.has(p.field));18}1920export { FIELD_LABEL, fieldLabel } from "@/lib/field-labels";2122/** Value + inline provenance popover for the rows backing `fields`. Renders nothing extra when no provenance exists. */23export function WithProvenance({ items, fields, children, className }: { items: ProvenanceDTO[] | undefined; fields: string | string[]; children: ReactNode; className?: string }) {24  const rows = provFor(items, ...(Array.isArray(fields) ? fields : [fields]));25  return (26    <span className={cn("inline-flex items-center gap-1", className)}>27      {children}28      {rows.length > 0 && <ProvenancePopover items={rows} size={12} />}29    </span>30  );31}3233/** Full provenance table: field, value, source, first/last observed, confidence, method, link. Stacks to cards < 640 px. */34export function ProvenanceTable({ items, emptyDescription = "No field-level provenance has been published for this record yet." }: { items: ProvenanceDTO[]; emptyDescription?: string }) {35  if (!items.length) return <EmptyState compact title="No provenance rows" description={emptyDescription} />;36  const sorted = [...items].sort((a, b) => fieldLabel(a.field).localeCompare(fieldLabel(b.field)) || (a.lastObserved < b.lastObserved ? 1 : -1));37  return (38    <Table dense>39      <THead>40        <tr>41          <Th>Field</Th>42          <Th>Value</Th>43          <Th>Source</Th>44          <Th className="hidden lg:table-cell">First observed</Th>45          <Th className="hidden lg:table-cell">Last observed</Th>46          <Th>Confidence</Th>47          <Th className="hidden xl:table-cell">Method</Th>48          <Th className="hidden md:table-cell">Link</Th>49        </tr>50      </THead>51      <TBody>52        {sorted.map((p, i) => (53          <Tr key={`${p.field}-${p.sourceId}-${i}`}>54            <Td primary>55              <span className="inline-flex items-center gap-1.5">56                {fieldLabel(p.field)}57                {p.isWinner && (58                  <span className="inline-flex h-[16px] items-center rounded-xs border border-accent/40 bg-accent-soft px-1 text-[9.5px] font-medium uppercase tracking-[0.06em] text-accent-ink" title="Winner — the observation backing the displayed value">59                    shown60                  </span>61                )}62              </span>63            </Td>64            <Td label="Value" className="figure max-w-[220px] text-[12.5px] lg:max-w-[300px]">65              <span className="min-w-0 [overflow-wrap:anywhere]">66                {p.isEstimate && <EstimateMark className="mr-0.5" />}67                {fmtValue(p.value, valueHint(p.field))}68                {p.scope && <ScopeBadge scope={p.scope} className="ml-1.5 align-middle" />}69              </span>70            </Td>71            <Td label="Source">72              <span className="inline-flex min-w-0 flex-wrap items-center gap-1.5 [overflow-wrap:anywhere]">73                <span className="text-ink">{p.sourceName}</span>74                <SourceBadge kind={p.sourceKind} name={p.sourceName} url={p.url} retrievedAt={p.retrievedAt} documentId={p.documentId} />75              </span>76            </Td>77            <Td label="First observed" className="figure hidden text-[12px] text-ink-3 lg:table-cell">78              {fmtDate(p.firstObserved)}79            </Td>80            <Td label="Last observed" className="figure hidden text-[12px] text-ink-3 lg:table-cell">81              {fmtDate(p.lastObserved)}82            </Td>83            <Td label="Confidence">84              <ConfidenceBadge level={p.confidence} size="xs" />85            </Td>86            <Td label="Method" className="figure hidden text-[11.5px] text-ink-3 xl:table-cell">87              {p.method ?? "—"}88            </Td>89            <Td label="Link" className="hidden md:table-cell">90              <a href={p.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-[12px] text-ink-2 hover:text-accent" title={p.url}>91                source <ExternalLink className="size-3" />92              </a>93            </Td>94          </Tr>95        ))}96      </TBody>97    </Table>98  );99}100