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%
11.5 KB · 206 lines tsx
Raw Blame History
1import Link from 'next/link';2import { Section, KV, Note } from '@/components/ui/section';3import { EmptyState } from '@/components/ui/empty-state';4import { Badge, ClaimBadge } from '@/components/ui/badge';5import { SourceBadge } from '@/components/ui/source-badge';6import { Freshness } from '@/components/ui/freshness';7import { CompletenessDots } from '@/components/ui/completeness';8import { loadProvenance, toInfo } from '@/lib/queries/provenance';9import { fmtInt, humanize, isoDate } from '@/lib/format';10import { KeyFigures, CompletenessRow } from '../key-figures';11import type { CancerBundle } from '../load';1213export async function OverviewTab({ b }: { b: CancerBundle }) {14  const { cancer: c, counters, parents, children, aliases, anatomy } = b;15  const prov = await loadProvenance([c.description_provenance_id]);16  const descProv = toInfo(prov.get(c.description_provenance_id ?? -1), 'raw');17  const byType = new Map<string, typeof parents>();18  for (const p of parents) byType.set(p.hierarchy_type, [...(byType.get(p.hierarchy_type) ?? []), p]);19  const childrenByType = new Map<string, typeof children>();20  for (const ch of children) childrenByType.set(ch.hierarchy_type, [...(childrenByType.get(ch.hierarchy_type) ?? []), ch]);21  const synonyms = aliases.filter((a) => a.alias_type !== 'preferred' && a.alias_type !== 'abbreviation');2223  const knows = [24    { label: `Taxonomy: ${parents.length} parent${parents.length === 1 ? '' : 's'}, ${children.length} ${children.length === 1 ? 'child' : 'children'}`, href: `/cancer/${c.slug}#taxonomy` },25    ...(anatomy.length ? [{ label: `Anatomy: ${anatomy.map((a) => a.name).join(', ')}`, href: `/cancer/${c.slug}#anatomy` }] : []),26    ...(b.codes.length ? [{ label: `${b.codes.length} cross-reference codes`, href: `/cancer/${c.slug}/sources` }] : []),27  ];2829  return (30    <div className="space-y-8">31      {/* Key figures strip (§43, §309): full width, above the definition. */}32      <div>33        <KeyFigures b={b} />34        <CompletenessRow completeness={counters?.completeness} computedAt={counters?.updated_at} />35      </div>36    <div className="grid gap-8 lg:grid-cols-[1.5fr_1fr]">37      <div className="space-y-8">38        <Section id="definition" kicker="Definition" title="What this entity is">39          {c.description ? (40            <>41              <p className="max-w-3xl text-[15px] leading-relaxed text-ink">{c.description}</p>42              {/* div, not p: the SourceBadge popover contains a <dl>, which the HTML parser uses to close an open <p> → hydration mismatch (React #418). */}43              <div className="mt-2 flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">44                Definition text as published by {descProv ? <SourceBadge p={descProv} /> : 'the source terminology'} <ClaimBadge kind="curated" />45              </div>46              <Freshness dataUpdatedAt={prov.get(c.description_provenance_id ?? -1)?.retrieved_at ?? c.updated_at} sourceVersion={prov.get(c.description_provenance_id ?? -1)?.dataset_version ?? c.classification_version} />47            </>48          ) : (49            <EmptyState title="Definition not yet available" knows={knows}>50              The NCIt definition is attached when the NCIt connector ingests this concept{c.primary_ncit_code ? ` (${c.primary_ncit_code})` : ''}. CancerIndex does not write its own definitions.51            </EmptyState>52          )}53        </Section>5455        <Section id="taxonomy" kicker="Taxonomy" title="Position in the hierarchies" description="Several hierarchies coexist; edges are listed per hierarchy type with their source.">56          {parents.length === 0 && children.length === 0 ? (57            <EmptyState compact>No hierarchy edge for this entity yet.</EmptyState>58          ) : (59            <div className="grid gap-6 sm:grid-cols-2">60              <div>61                <p className="ci-kicker mb-1.5">Parents</p>62                {parents.length ? (63                  [...byType.entries()].map(([type, ps]) => (64                    <div key={type} className="mb-2">65                      <Badge tone="outline" className="mb-1">66                        {type}67                      </Badge>68                      <ul className="text-[13.5px]">69                        {ps.map((p) => (70                          <li key={`${type}-${p.id}`} className="py-0.5">71                            <Link className={`ci-link ${p.malignant ? '' : 'text-ink-3'}`} href={`/cancer/${p.slug}`}>72                              {p.canonical_name}73                            </Link>74                            <span className="ml-1.5 text-[11.5px] text-ink-3">{humanize(p.entity_type)}</span>75                          </li>76                        ))}77                      </ul>78                    </div>79                  ))80                ) : (81                  <p className="text-[13px] text-ink-3">Root node — no parent in any ingested hierarchy.</p>82                )}83              </div>84              <div>85                <p className="ci-kicker mb-1.5">Children ({fmtInt(children.length)})</p>86                {children.length ? (87                  [...childrenByType.entries()].map(([type, cs]) => (88                    <div key={type} className="mb-2">89                      <Badge tone="outline" className="mb-1">90                        {type}91                      </Badge>92                      <ul className="max-h-[420px] overflow-y-auto text-[13.5px]">93                        {cs.map((ch) => (94                          <li key={`${type}-${ch.id}`} className="flex items-baseline justify-between gap-2 py-0.5">95                            <Link className={`ci-link ${ch.malignant ? '' : 'text-ink-3'}`} href={`/cancer/${ch.slug}`}>96                              {ch.canonical_name}97                            </Link>98                            {ch.child_count > 0 ? <span className="ci-num text-[11px] text-ink-3">{ch.child_count} sub</span> : null}99                          </li>100                        ))}101                      </ul>102                    </div>103                  ))104                ) : (105                  <p className="text-[13px] text-ink-3">Leaf node — no children.</p>106                )}107              </div>108            </div>109          )}110          {counters && counters.descendant_count > children.length ? <p className="mt-2 text-[12px] text-ink-3">{fmtInt(counters.descendant_count)} descendants in total; counters on this page aggregate over all of them.</p> : null}111        </Section>112113        <Section id="anatomy" kicker="Anatomy" title="Anatomical sites">114          {anatomy.length ? (115            <ul className="flex flex-wrap gap-2 text-[13.5px]">116              {anatomy.map((a) => (117                <li key={`${a.site_id}-${a.relation}`}>118                  <Link href={`/cancers?site=${a.slug}`} className="inline-flex items-center gap-1.5 border border-rule px-2 py-1 no-underline hover:border-accent">119                    <span className="text-ink">{a.name}</span>120                    {a.relation !== 'primary' ? <Badge tone="outline">{a.relation}</Badge> : null}121                    {a.ncit_code ? <span className="ci-mono text-[10.5px] text-ink-4">{a.ncit_code}</span> : null}122                  </Link>123                </li>124              ))}125            </ul>126          ) : (127            <EmptyState compact>No anatomical site linked yet.</EmptyState>128          )}129        </Section>130      </div>131132      <aside className="space-y-8">133        <Section id="facts" kicker="Record" title="Identity" level={3}>134          <KV135            items={[136              { k: 'CancerIndex ID', v: <span className="ci-mono">{c.id}</span> },137              { k: 'Entity type', v: humanize(c.entity_type) },138              { k: 'Malignant', v: c.malignant ? 'Yes' : 'No' },139              { k: 'Solid / hematologic', v: c.hematologic ? 'Hematologic' : c.solid_tumor ? 'Solid tumor' : '—' },140              { k: 'Pediatric relevant', v: c.pediatric_relevant ? 'Yes' : 'Not flagged' },141              { k: 'Rare cancer', v: c.rare_cancer == null ? <span className="text-ink-3">Unknown (no incidence data yet)</span> : c.rare_cancer ? 'Yes' : 'No' },142              { k: 'Hierarchy depth', v: <span className="ci-num">{c.depth}</span> },143              { k: 'Classification version', v: c.classification_version ? <span className="ci-mono">{c.classification_version}</span> : null },144              { k: 'Semantic types', v: c.semantic_types.length ? c.semantic_types.join(', ') : null },145              { k: 'Status', v: humanize(c.status) },146              { k: 'Record updated', v: isoDate(c.updated_at) },147            ]}148          />149        </Section>150151        <Section id="completeness" kicker="Coverage" title="Data completeness" level={3}>152          <CompletenessDots153            filled={{154              taxonomy: true,155              epidemiology: counters?.epidemiology_obs_count ?? 0,156              survival: counters?.survival_obs_count ?? 0,157              genomics: counters?.cohort_count ?? 0,158              evidence: counters?.evidence_count ?? 0,159              drugs: counters?.drug_count ?? 0,160              trials: counters?.trial_count ?? 0,161              literature: counters?.publication_count ?? 0,162            }}163          />164          {counters ? (165            <KV166              className="mt-3"167              items={[168                { k: 'Trials (all / active)', v: <span className="ci-num">{fmtInt(counters.trial_count)} / {fmtInt(counters.active_trial_count)}</span> },169                { k: 'Publications (all / 5y)', v: <span className="ci-num">{fmtInt(counters.publication_count)} / {fmtInt(counters.publication_count_5y)}</span> },170                { k: 'Evidence items', v: <span className="ci-num">{fmtInt(counters.evidence_count)}</span> },171                { k: 'Genes / variants', v: <span className="ci-num">{fmtInt(counters.gene_count)} / {fmtInt(counters.variant_count)}</span> },172                { k: 'Drugs (any / approved)', v: <span className="ci-num">{fmtInt(counters.drug_count)} / {fmtInt(counters.approved_drug_count)}</span> },173                { k: 'Genomic cohorts', v: <span className="ci-num">{fmtInt(counters.cohort_count)}</span> },174                { k: 'Epidemiology / survival obs.', v: <span className="ci-num">{fmtInt(counters.epidemiology_obs_count)} / {fmtInt(counters.survival_obs_count)}</span> },175              ]}176            />177          ) : (178            <p className="mt-2 text-[12.5px] text-ink-3">Counters have not been computed for this entity yet (they are refreshed after each connector run). Only the taxonomy domain is populated.</p>179          )}180          {counters ? <Freshness dataUpdatedAt={counters.updated_at} extra="counters aggregate over descendants" /> : null}181        </Section>182183        <Section id="synonyms" kicker="Names" title={`Synonyms (${fmtInt(synonyms.length)})`} level={3}>184          {synonyms.length ? (185            <ul className="max-h-[300px] overflow-y-auto text-[13px]">186              {synonyms.map((a, i) => (187                <li key={`${a.alias}-${i}`} className="flex items-baseline justify-between gap-2 border-b border-rule py-0.5">188                  <span>{a.alias}</span>189                  <span className="text-[11px] text-ink-3">190                    {a.alias_type}191                    {a.source_terminology ? ` · ${a.source_terminology}` : ''}192                  </span>193                </li>194              ))}195            </ul>196          ) : (197            <p className="text-[13px] text-ink-3">Only the preferred name is recorded so far.</p>198          )}199        </Section>200        <Note>CancerIndex is a research and information platform. It does not diagnose and does not recommend treatment.</Note>201      </aside>202    </div>203    </div>204  );205}206