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.8 KB · 128 lines tsx
Raw Blame History
1import type { ReactNode } from 'react';23type Tone = 'neutral' | 'accent' | 'warn' | 'danger' | 'ok' | 'outline';45// Tones map to short CSS classes (globals.css `.ci-badge--*`) instead of long Tailwind strings: dense6// tables repeat badges hundreds of times and every className is shipped twice (HTML + RSC payload).7const TONES: Record<Tone, string> = {8  neutral: 'ci-badge--neutral',9  accent: 'ci-badge--accent',10  warn: 'ci-badge--warn',11  danger: 'ci-badge--danger',12  ok: 'ci-badge--ok',13  outline: 'ci-badge--outline',14};1516export function Badge({ children, tone = 'neutral', title, className = '', mono = false }: { children: ReactNode; tone?: Tone; title?: string; className?: string; mono?: boolean }) {17  // Only spread `title` when set: an undefined prop is serialized as "$undefined" in the RSC payload.18  const extra = title ? { title } : {};19  return (20    <span {...extra} className={`ci-badge ${TONES[tone]}${mono ? ' ci-mono' : ''}${className ? ` ${className}` : ''}`}>21      {children}22    </span>23  );24}2526/**27 * Scientific safety label (CLAUDE.md §3). The seven categories are never merged.28 */29export type ClaimKind = 'observed' | 'published' | 'curated' | 'regulatory' | 'guideline' | 'computed' | 'ai';3031const CLAIM: Record<ClaimKind, { label: string; title: string; tone: Tone }> = {32  observed: { label: 'Observed', title: 'Observed data — registry or measured counts reported by the source', tone: 'neutral' },33  published: { label: 'Published', title: 'Published evidence — peer-reviewed literature or trial registration', tone: 'neutral' },34  curated: { label: 'Curated', title: 'Curated evidence — expert-curated knowledge base (e.g. CIViC, NCIt)', tone: 'neutral' },35  regulatory: { label: 'Regulatory', title: 'Regulatory status — approval or label decision by a named authority and jurisdiction', tone: 'neutral' },36  guideline: { label: 'Guideline', title: 'Clinical guideline statement', tone: 'neutral' },37  computed: { label: 'Computed', title: 'Computed metric — derived by CancerIndex with a versioned formula', tone: 'accent' },38  ai: { label: 'AI-generated', title: 'AI-generated synthesis — not enabled in Phase 1', tone: 'warn' },39};4041export function ClaimBadge({ kind, className = '' }: { kind: ClaimKind; className?: string }) {42  const c = CLAIM[kind];43  return (44    <Badge tone={c.tone} title={c.title} className={className}>45      {c.label}46    </Badge>47  );48}4950export function claimKindFromCategory(cat: string | null | undefined): ClaimKind {51  switch (cat) {52    case 'observed_data':53      return 'observed';54    case 'published_evidence':55      return 'published';56    case 'regulatory_status':57      return 'regulatory';58    case 'clinical_guideline':59      return 'guideline';60    case 'computed_metric':61      return 'computed';62    case 'ai_generated_synthesis':63      return 'ai';64    default:65      return 'curated';66  }67}6869export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA';7071export function ConfidenceBadge({ level, className = '' }: { level: string | null | undefined; className?: string }) {72  const l = (level ?? 'INSUFFICIENT_DATA').toUpperCase() as Confidence;73  const map: Record<Confidence, { tone: Tone; label: string; title: string }> = {74    HIGH: { tone: 'ok', label: 'High confidence', title: 'Observed data from a primary source with complete coverage for the scope' },75    MEDIUM: { tone: 'neutral', label: 'Medium confidence', title: 'Estimated, modelled or partially covered data' },76    LOW: { tone: 'warn', label: 'Low confidence', title: 'Small denominators, heterogeneous definitions or indirect mapping' },77    INSUFFICIENT_DATA: { tone: 'outline', label: 'Insufficient data', title: 'Not enough data to assign a confidence level' },78  };79  const m = map[l] ?? map.INSUFFICIENT_DATA;80  return (81    <Badge tone={m.tone} title={m.title} className={className}>82      {m.label}83    </Badge>84  );85}8687/** True for mappings made through a shared identifier or a curated/ontology one-to-one match. */88export function isExactMatch(matchType: string | null | undefined): boolean {89  return matchType === 'EXACT_IDENTIFIER' || matchType === 'CURATED_EXACT' || matchType === 'ONTOLOGY_EXACT';90}9192/** Entity-mapping confidence (CLAUDE.md §221). */93export function MatchBadge({ matchType, className = '' }: { matchType: string | null | undefined; className?: string }) {94  const m = matchType ?? 'UNRESOLVED';95  const tone: Tone = m === 'EXACT_IDENTIFIER' || m === 'CURATED_EXACT' || m === 'ONTOLOGY_EXACT' ? 'ok' : m === 'UNRESOLVED' ? 'danger' : m === 'PROBABILISTIC' ? 'warn' : 'neutral';96  const titles: Record<string, string> = {97    EXACT_IDENTIFIER: 'Mapped through a shared identifier (e.g. NCIt code)',98    CURATED_EXACT: 'Curated one-to-one mapping',99    ONTOLOGY_EXACT: 'Exact match through an ontology cross-reference',100    CURATED_BROADER: 'Mapped to a broader concept by a curator',101    CURATED_NARROWER: 'Mapped to a narrower concept by a curator',102    ALIAS: 'Matched on a known alias after normalization',103    PROBABILISTIC: 'Probabilistic string match — treat with caution',104    UNRESOLVED: 'Not mapped to a CancerIndex entity',105  };106  return (107    <Badge tone={tone} mono title={titles[m] ?? m} className={className}>108      {m}109    </Badge>110  );111}112113export function StatusBadge({ status, className = '' }: { status: string | null | undefined; className?: string }) {114  const s = (status ?? 'unknown').toLowerCase();115  const tone: Tone = ['healthy', 'succeeded', 'active', 'approved', 'recruiting', 'accepted', 'validated'].includes(s)116    ? 'ok'117    : ['failed', 'failing', 'blocked', 'retracted', 'withdrawn', 'rejected', 'aborted'].includes(s)118      ? 'danger'119      : ['degraded', 'partial', 'review', 'awaiting_credentials', 'restricted', 'paused', 'candidate', 'submitted'].includes(s)120        ? 'warn'121        : 'neutral';122  return (123    <Badge tone={tone} className={className}>124      {s.replace(/_/g, ' ')}125    </Badge>126  );127}128