spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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