spb/cancerindex
Public
1import type { ReactNode } from 'react';23type Tone = 'neutral' | 'accent' | 'warn' | 'danger' | 'ok' | 'outline';45const TONES: Record<Tone, string> = {6 neutral: 'bg-paper-3 text-ink-2 border-transparent',7 accent: 'bg-accent-soft text-accent-2 border-transparent',8 warn: 'bg-warn-soft text-warn border-transparent',9 danger: 'bg-danger-soft text-danger border-transparent',10 ok: 'bg-ok-soft text-ok border-transparent',11 outline: 'bg-transparent text-ink-2 border-rule-strong',12};1314export function Badge({ children, tone = 'neutral', title, className = '', mono = false }: { children: ReactNode; tone?: Tone; title?: string; className?: string; mono?: boolean }) {15 return (16 <span title={title} className={`inline-flex items-center gap-1 rounded-sm border px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide ${mono ? 'ci-mono' : ''} ${TONES[tone]} ${className}`}>17 {children}18 </span>19 );20}2122/**23 * Scientific safety label (CLAUDE.md §3). The seven categories are never merged.24 */25export type ClaimKind = 'observed' | 'published' | 'curated' | 'regulatory' | 'guideline' | 'computed' | 'ai';2627const CLAIM: Record<ClaimKind, { label: string; title: string; tone: Tone }> = {28 observed: { label: 'Observed', title: 'Observed data — registry or measured counts reported by the source', tone: 'neutral' },29 published: { label: 'Published', title: 'Published evidence — peer-reviewed literature or trial registration', tone: 'neutral' },30 curated: { label: 'Curated', title: 'Curated evidence — expert-curated knowledge base (e.g. CIViC, NCIt)', tone: 'neutral' },31 regulatory: { label: 'Regulatory', title: 'Regulatory status — approval or label decision by a named authority and jurisdiction', tone: 'neutral' },32 guideline: { label: 'Guideline', title: 'Clinical guideline statement', tone: 'neutral' },33 computed: { label: 'Computed', title: 'Computed metric — derived by CancerIndex with a versioned formula', tone: 'accent' },34 ai: { label: 'AI-generated', title: 'AI-generated synthesis — not enabled in Phase 1', tone: 'warn' },35};3637export function ClaimBadge({ kind, className = '' }: { kind: ClaimKind; className?: string }) {38 const c = CLAIM[kind];39 return (40 <Badge tone={c.tone} title={c.title} className={className}>41 {c.label}42 </Badge>43 );44}4546export function claimKindFromCategory(cat: string | null | undefined): ClaimKind {47 switch (cat) {48 case 'observed_data':49 return 'observed';50 case 'published_evidence':51 return 'published';52 case 'regulatory_status':53 return 'regulatory';54 case 'clinical_guideline':55 return 'guideline';56 case 'computed_metric':57 return 'computed';58 case 'ai_generated_synthesis':59 return 'ai';60 default:61 return 'curated';62 }63}6465export type Confidence = 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA';6667export function ConfidenceBadge({ level, className = '' }: { level: string | null | undefined; className?: string }) {68 const l = (level ?? 'INSUFFICIENT_DATA').toUpperCase() as Confidence;69 const map: Record<Confidence, { tone: Tone; label: string; title: string }> = {70 HIGH: { tone: 'ok', label: 'High confidence', title: 'Observed data from a primary source with complete coverage for the scope' },71 MEDIUM: { tone: 'neutral', label: 'Medium confidence', title: 'Estimated, modelled or partially covered data' },72 LOW: { tone: 'warn', label: 'Low confidence', title: 'Small denominators, heterogeneous definitions or indirect mapping' },73 INSUFFICIENT_DATA: { tone: 'outline', label: 'Insufficient data', title: 'Not enough data to assign a confidence level' },74 };75 const m = map[l] ?? map.INSUFFICIENT_DATA;76 return (77 <Badge tone={m.tone} title={m.title} className={className}>78 {m.label}79 </Badge>80 );81}8283/** Entity-mapping confidence (CLAUDE.md §221). */84export function MatchBadge({ matchType, className = '' }: { matchType: string | null | undefined; className?: string }) {85 const m = matchType ?? 'UNRESOLVED';86 const tone: Tone = m === 'EXACT_IDENTIFIER' || m === 'CURATED_EXACT' || m === 'ONTOLOGY_EXACT' ? 'ok' : m === 'UNRESOLVED' ? 'danger' : m === 'PROBABILISTIC' ? 'warn' : 'neutral';87 const titles: Record<string, string> = {88 EXACT_IDENTIFIER: 'Mapped through a shared identifier (e.g. NCIt code)',89 CURATED_EXACT: 'Curated one-to-one mapping',90 ONTOLOGY_EXACT: 'Exact match through an ontology cross-reference',91 CURATED_BROADER: 'Mapped to a broader concept by a curator',92 CURATED_NARROWER: 'Mapped to a narrower concept by a curator',93 ALIAS: 'Matched on a known alias after normalization',94 PROBABILISTIC: 'Probabilistic string match — treat with caution',95 UNRESOLVED: 'Not mapped to a CancerIndex entity',96 };97 return (98 <Badge tone={tone} mono title={titles[m] ?? m} className={className}>99 {m}100 </Badge>101 );102}103104export function StatusBadge({ status, className = '' }: { status: string | null | undefined; className?: string }) {105 const s = (status ?? 'unknown').toLowerCase();106 const tone: Tone = ['healthy', 'succeeded', 'active', 'approved', 'recruiting', 'accepted', 'validated'].includes(s)107 ? 'ok'108 : ['failed', 'failing', 'blocked', 'retracted', 'withdrawn', 'rejected', 'aborted'].includes(s)109 ? 'danger'110 : ['degraded', 'partial', 'review', 'awaiting_credentials', 'restricted', 'paused', 'candidate', 'submitted'].includes(s)111 ? 'warn'112 : 'neutral';113 return (114 <Badge tone={tone} className={className}>115 {s.replace(/_/g, ' ')}116 </Badge>117 );118}119