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%
7.6 KB · 147 lines tsx
Raw Blame History
1import Link from 'next/link';2import { Section, KV, Note } from '@/components/ui/section';3import { Badge, ClaimBadge } from '@/components/ui/badge';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { SourceBadge } from '@/components/ui/source-badge';7import { gapComponentForCancer, gapScopeKey } from '@/lib/queries/research-gap';8import { fmtInt, fmtPct, fmtValue, humanize } from '@/lib/format';910function ratioText(v: number | null): string {11  if (v == null) return 'undefined (no activity)';12  const abs = Math.abs(v);13  const factor = 2 ** abs;14  const how = factor >= 10 ? `${fmtInt(factor)}×` : `${fmtValue(factor, 'ratio')}×`;15  if (abs < 0.15) return 'about proportional to its share of deaths';16  return v > 0 ? `death share ${how} its activity share` : `activity share ${how} its death share`;17}1819/**20 * Small server component for a cancer page: that cancer's research-gap components in the latest21 * burden scope of a geography (default USA, both sexes). Shows the inputs behind each ratio and links22 * to the full index and the two ratio rankings. Renders an EmptyState when the cancer is not a23 * top-level entity of the scope (components exist only for top-level cancers with a mortality24 * observation — CLAUDE.md §246-247).25 */26export async function CancerGapCard({ cancerId, geography = 'USA' }: { cancerId: string; geography?: string }) {27  const res = await gapComponentForCancer(cancerId, geography);28  return (29    <Section30      id="research-gap"31      kicker="Unmet need · computed"32      title="Research gap"33      level={3}34      actions={35        <Link href="/research-gap" className="ci-link">36          Full index →37        </Link>38      }39    >40      {!res ? (41        <EmptyState compact title="No research-gap components for this entity" knows={[{ label: 'Research Gap Index (top-level cancers)', href: '/research-gap' }, { label: 'Methodology', href: '/methodology#research-gap' }]}>42          Components are computed for top-level cancers with a mortality observation in a burden scope (United States only for now). Entities below the top level inherit no value: shares would double-count their parent.43        </EmptyState>44      ) : (45        (() => {46          const { scope, row, sums } = res;47          const key = gapScopeKey(scope);48          const src = <SourceBadge p={{ sourceSlug: scope.source_slug, sourceName: scope.source_name, dataset: `mortality_count · ${scope.geography} · ${scope.year} · ${scope.sex}`, layer: 'normalized' }} compact />;49          return (50            <>51              <p className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">52                <ClaimBadge kind="computed" />53                <span>54                  {scope.geography} · {scope.year} · {scope.sex === 'all' ? 'both sexes' : humanize(scope.sex)} · all ages · {fmtInt(sums.eligible)} eligible top-level cancers55                </span>56                {row.eligible ? (57                  <Badge tone="ok">eligible</Badge>58                ) : (59                  <Badge tone="outline" title={row.ineligible_reason ?? undefined}>60                    ineligible61                  </Badge>62                )}63              </p>64              <KV65                items={[66                  {67                    k: `Deaths ${scope.year}`,68                    v: (69                      <>70                        <span className="ci-num">{fmtInt(row.deaths)}</span> {src}71                        {row.death_share != null ? <span className="text-ink-3"> · {fmtPct(row.death_share, 1)} of {fmtInt(sums.deaths)} in the eligible set</span> : null}72                      </>73                    ),74                  },75                  {76                    k: 'Active trials',77                    v: (78                      <>79                        <span className="ci-num">{fmtInt(row.active_trials)}</span> <Link href="/source/clinicaltrials" className="ci-src">clinicaltrials</Link>80                        {row.trial_share != null ? <span className="text-ink-3"> · {fmtPct(row.trial_share, 1)} of {fmtInt(sums.activeTrials)}</span> : null}81                        <span className="text-ink-3"> · {fmtValue(row.trials_per_1000_deaths, 'per_1000_deaths')} per 1,000 deaths</span>82                      </>83                    ),84                  },85                  {86                    k: 'Publications 5 y',87                    v: (88                      <>89                        <span className="ci-num">{fmtInt(row.publications_5y)}</span> <Link href="/source/pubmed" className="ci-src">pubmed</Link>90                        {row.publication_share != null ? <span className="text-ink-3"> · {fmtPct(row.publication_share, 1)} of {fmtInt(sums.publications5y)}</span> : null}91                        <span className="text-ink-3"> · {fmtValue(row.publications_per_1000_deaths, 'per_1000_deaths')} per 1,000 deaths</span>92                      </>93                    ),94                  },95                  {96                    k: 'Trial gap ratio',97                    v: row.eligible ? (98                      <>99                        <span className={`ci-num font-medium ${(row.trial_gap_ratio ?? 0) >= 1 ? 'text-danger' : ''}`}>{fmtValue(row.trial_gap_ratio, 'log2_ratio')}</span>100                        <span className="text-ink-3"> log₂ · {ratioText(row.trial_gap_ratio)}</span>101                        {row.trial_gap_rank != null ? (102                          <>103                            {' '}104                            ·{' '}105                            <Link href={`/rankings/trial_gap_ratio?scope=${encodeURIComponent(key)}`} className="ci-link">106                              rank #{row.trial_gap_rank} of {fmtInt(sums.eligible)}107                            </Link>108                          </>109                        ) : null}110                      </>111                    ) : (112                      <span className="text-ink-3">not computed — {row.ineligible_reason?.replace(/_/g, ' ')}</span>113                    ),114                  },115                  {116                    k: 'Research gap ratio',117                    v: row.eligible ? (118                      <>119                        <span className={`ci-num font-medium ${(row.research_gap_ratio ?? 0) >= 1 ? 'text-danger' : ''}`}>{fmtValue(row.research_gap_ratio, 'log2_ratio')}</span>120                        <span className="text-ink-3"> log₂ · {ratioText(row.research_gap_ratio)}</span>121                        {row.research_gap_rank != null ? (122                          <>123                            {' '}124                            ·{' '}125                            <Link href={`/rankings/research_gap_ratio?scope=${encodeURIComponent(key)}`} className="ci-link">126                              rank #{row.research_gap_rank} of {fmtInt(sums.eligible)}127                            </Link>128                          </>129                        ) : null}130                      </>131                    ) : (132                      <span className="text-ink-3">not computed — {row.ineligible_reason?.replace(/_/g, ' ')}</span>133                    ),134                  },135                  { k: 'Formula', v: <span className="ci-mono text-[12px]">log2(death share ÷ activity share) · {row.formula_version}</span> },136                ]}137              />138              <Note>A gap ratio compares registered activity with deaths inside one scope. It is a signal, not a judgement of research quality; trial counts include NCIt descendants, literature counts are query-based per entity.</Note>139              <Freshness dataUpdatedAt={row.computed_at} extra={`scope ${key}`} />140            </>141          );142        })()143      )}144    </Section>145  );146}147