SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
5.5 KB · 132 lines tsx
Raw Blame History
1import Link from 'next/link';2import { ExternalLink } from 'lucide-react';3import { fmtDate } from '@/lib/format';45export interface ProvenanceInfo {6  sourceSlug: string;7  sourceName?: string | null;8  dataset?: string | null;9  datasetVersion?: string | null;10  retrievedAt?: Date | string | null;11  sourceUrl?: string | null;12  layer?: 'raw' | 'normalized' | 'canonical' | 'derived' | 'ranked';13  license?: string | null;14  evidenceType?: string | null;15  pmid?: string | null;16  ingestRunId?: string | null;17  note?: string | null;18}1920/** One-line provenance summary for `title` attributes on compact badges (dataset · version · retrieved). */21export function provenanceTitle(p: ProvenanceInfo): string {22  const bits = [p.sourceName ?? p.sourceSlug];23  if (p.dataset) bits.push(p.dataset);24  if (p.datasetVersion) bits.push(`version ${p.datasetVersion}`);25  if (p.retrievedAt) bits.push(`retrieved ${fmtDate(p.retrievedAt)}`);26  if (p.layer === 'derived' || p.layer === 'ranked') bits.push('computed by CancerIndex');27  return bits.join(' · ');28}2930/**31 * Small source badge; hover / focus reveals the provenance popover (source, dataset, version,32 * retrieved date, raw vs normalized, link). CSS-only so it works inside server components.33 *34 * `compact`: link only, no popover — for dense tables. When every row shares one source/dataset the35 * popover is shown once in the table caption; when provenance genuinely differs per row (e.g.36 * epidemiology observations from different datasets/years) pass `title` (or let it default to a37 * dataset · version · retrieved summary) so the detail stays one hover away without the markup.38 */39export function SourceBadge({ p, className = '', compact = false, title }: { p: ProvenanceInfo; className?: string; compact?: boolean; title?: string | null }) {40  if (compact) {41    // The visible slug is the link text; the "Source" column header gives the context and `title`42    // (dataset · version · retrieved) is exposed as the accessible description. No aria-label43    // duplicate: every attribute here is repeated hundreds of times per page (HTML + RSC payload).44    const t = title === null ? undefined : (title ?? (p.dataset || p.datasetVersion ? provenanceTitle(p) : undefined));45    const extra = t ? { title: t } : {};46    return (47      <Link href={`/source/${p.sourceSlug}`} className={`ci-src${className ? ` ${className}` : ''}`} {...extra}>48        {p.sourceSlug}49      </Link>50    );51  }52  return (53    <span className={`ci-pop ${className}`}>54      <Link href={`/source/${p.sourceSlug}`} className="ci-src" aria-label={`Source: ${p.sourceName ?? p.sourceSlug}. Open provenance.`}>55        {p.sourceSlug}56      </Link>57      <span className="ci-pop-panel" role="tooltip">58        <span className="ci-kicker block">Provenance</span>59        <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">60          <dt className="text-ink-3">Source</dt>61          <dd>{p.sourceName ?? p.sourceSlug}</dd>62          {p.dataset ? (63            <>64              <dt className="text-ink-3">Dataset</dt>65              <dd>{p.dataset}</dd>66            </>67          ) : null}68          {p.datasetVersion ? (69            <>70              <dt className="text-ink-3">Version</dt>71              <dd className="ci-mono">{p.datasetVersion}</dd>72            </>73          ) : null}74          {p.retrievedAt ? (75            <>76              <dt className="text-ink-3">Retrieved</dt>77              <dd>{fmtDate(p.retrievedAt)}</dd>78            </>79          ) : null}80          <dt className="text-ink-3">Layer</dt>81          <dd>{p.layer === 'derived' || p.layer === 'ranked' ? `${p.layer} (computed by CancerIndex)` : p.layer === 'raw' ? 'raw (as published)' : `${p.layer ?? 'normalized'} (units and labels harmonized; values unchanged)`}</dd>82          {p.evidenceType ? (83            <>84              <dt className="text-ink-3">Evidence</dt>85              <dd>{p.evidenceType.replace(/_/g, ' ')}</dd>86            </>87          ) : null}88          {p.license ? (89            <>90              <dt className="text-ink-3">License</dt>91              <dd>{p.license}</dd>92            </>93          ) : null}94          {p.pmid ? (95            <>96              <dt className="text-ink-3">PMID</dt>97              <dd className="ci-mono">{p.pmid}</dd>98            </>99          ) : null}100          {p.ingestRunId ? (101            <>102              <dt className="text-ink-3">Run</dt>103              <dd className="ci-mono break-all">{p.ingestRunId}</dd>104            </>105          ) : null}106        </dl>107        {p.note ? <span className="mt-1 block text-ink-3">{p.note}</span> : null}108        {p.sourceUrl ? (109          <a className="ci-link mt-1.5 inline-flex items-center gap-1" href={p.sourceUrl} target="_blank" rel="noopener noreferrer">110            Open at source <ExternalLink className="h-3 w-3" aria-hidden />111          </a>112        ) : null}113      </span>114    </span>115  );116}117118/**119 * Table caption used by every dense list: ONE provenance popover for the whole table, the claim120 * label and a count sentence. Rows then carry compact badges only.121 */122export function TableProvenance({ p, claim, children, className = '' }: { p: ProvenanceInfo; claim?: React.ReactNode; children?: React.ReactNode; className?: string }) {123  return (124    // <div>, not <p>: the popover contains a <dl>, which the HTML parser would close a <p> on (React #418 in production).125    <div className={`mb-2 flex flex-wrap items-center gap-2 text-[12px] text-ink-3 ${className}`}>126      <SourceBadge p={p} />127      {claim}128      {children ? <span>{children}</span> : null}129    </div>130  );131}132