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%
10.6 KB · 185 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Note } from '@/components/ui/section';4import { EmptyState } from '@/components/ui/empty-state';5import { Badge, ClaimBadge } from '@/components/ui/badge';6import { SourceBadge } from '@/components/ui/source-badge';7import { Freshness } from '@/components/ui/freshness';8import { listBiomarkers, biomarkerKindFacets, kindLabel, BIOMARKER_KINDS, BIOMARKER_LINKS_FORMULA } from '@/lib/queries/biomarkers';9import { fmtInt } from '@/lib/format';10import { str, oneOf, withParams, type SP } from '@/lib/search-params';1112export const metadata: Metadata = {13  title: 'Biomarkers',14  description: 'Canonical oncology biomarkers with verified NCIt concepts, anchor genes, assay conventions, and links derived from curated evidence, approvals and trials.',15  alternates: { canonical: '/biomarkers' },16};17// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages.18export const revalidate = 600;1920const KIND_FILTER = ['', ...BIOMARKER_KINDS] as const;2122export default async function BiomarkersPage({ searchParams }: { searchParams: Promise<SP> }) {23  const sp = await searchParams;24  const q = str(sp, 'q');25  const kind = oneOf(sp, 'kind', KIND_FILTER, '');26  const [rows, facets] = await Promise.all([listBiomarkers({ kind: kind || undefined, q: q || undefined }), biomarkerKindFacets()]);27  const href = (o: Record<string, string | number | null | undefined>) => `/biomarkers${withParams({ q, kind }, o)}`;28  const total = facets.reduce((s, f) => s + f.n, 0);29  const latest = rows.reduce<Date | string | null>((m, r) => (m == null || String(r.updated_at) > String(m) ? r.updated_at : m), null);30  const verification = rows[0]?.measurement.verification;3132  return (33    <div>34      <PageHeader35        kicker="Biomarkers"36        title="Biomarkers"37        lede="A biomarker entry is an identity record — what is measured, by which assay family, the NCIt concept it maps to and the gene it is anchored to. Every link to cancers, drugs, approvals and trials is derived at query time from curated evidence and registries; nothing on this page grades clinical utility or states a threshold of its own."38      />3940      <form method="get" action="/biomarkers" className="flex flex-wrap items-end gap-2 border-y border-rule py-3 text-[13.5px]">41        <label className="flex flex-col gap-1">42          <span className="ci-kicker">Name, alias, gene or NCIt code</span>43          <input name="q" defaultValue={q} placeholder="e.g. HER2, dMMR, CD274, C98342" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />44        </label>45        {kind ? <input type="hidden" name="kind" value={kind} /> : null}46        <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">47          Apply48        </button>49      </form>5051      <nav aria-label="Kind" className="mt-3 flex flex-wrap gap-1.5 text-[12.5px]">52        <Link href={href({ kind: '' })} aria-current={!kind ? 'page' : undefined} className="ci-chip">53          All <span className="ci-num text-ink-3">{fmtInt(total)}</span>54        </Link>55        {facets.map((f) => (56          <Link key={f.kind} href={href({ kind: f.kind })} aria-current={kind === f.kind ? 'page' : undefined} className="ci-chip">57            {kindLabel(f.kind)} <span className="ci-num text-ink-3">{f.n}</span>58          </Link>59        ))}60      </nav>6162      <div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-ink-2">63        <span>64          <span className="ci-num font-medium text-ink">{fmtInt(rows.length)}</span> biomarker{rows.length === 1 ? '' : 's'}65        </span>66        <SourceBadge p={{ sourceSlug: 'ncit-evs', sourceName: 'NCI Thesaurus (NCIt)', dataset: verification ? `EVS REST API, NCIt ${verification.ncitVersion}` : 'EVS REST API', retrievedAt: verification?.verifiedAt ?? null, layer: 'canonical', note: 'Identity metadata is curated; every NCIt code was fetched from the EVS REST API and recorded with its version and date.' }} />67        <ClaimBadge kind="curated" />68        <span className="text-ink-3">identity ·</span>69        <ClaimBadge kind="computed" />70        <span className="text-ink-3">71          counts, rule <span className="ci-mono">{BIOMARKER_LINKS_FORMULA}</span> ·{' '}72          <Link className="ci-link" href="/methodology#biomarkers">73            methodology74          </Link>75        </span>76      </div>7778      {rows.length === 0 ? (79        <div className="mt-3">80          <EmptyState title={total === 0 ? 'Biomarkers not yet available' : 'No biomarker matches'} knows={[{ label: 'Genes', href: '/genes' }, { label: 'Drugs', href: '/drugs' }, { label: 'Approvals', href: '/approvals' }]}>81            {total === 0 ? 'The biomarker catalogue has not been seeded on this environment (pnpm db:seed).' : 'Try a different name, alias, gene symbol or NCIt code, or clear the kind filter.'}82          </EmptyState>83        </div>84      ) : (85        <>86          <div className="ci-table-wrap mt-3">87            <table className="ci-table">88              <thead>89                <tr>90                  <th scope="col">Biomarker</th>91                  <th scope="col">Kind</th>92                  <th scope="col">Gene(s)</th>93                  <th scope="col">NCIt</th>94                  <th scope="col" className="num" title="Distinct cancers mapped in ACCEPTED CIViC predictive / prognostic / diagnostic items in the biomarker's scope (computed)">95                    Cancers96                  </th>97                  <th scope="col" className="num" title="Drugs with predictive evidence: CIViC therapies in scope ∪ PREDICTS_RESPONSE_TO knowledge-edge targets (computed)">98                    Drugs99                  </th>100                  <th scope="col" className="num" title="Approval records of those drugs, or whose indication text names the biomarker (computed)">101                    Approvals102                  </th>103                  <th scope="col" title="Tumor-agnostic = at least one approval row with the source flag tumor_agnostic = true among those approvals; the curated flag alone is never shown as a badge">104                    Tissue-agnostic105                  </th>106                  <th scope="col" className="num" title="Trials with an active ClinicalTrials.gov status, an intervention mapped to a scope drug and a condition mapped to a scope cancer (computed)">107                    Active trials108                  </th>109                </tr>110              </thead>111              <tbody>112                {rows.map((b) => {113                  const noScope = b.gene_ids.length === 0 && b.variant_ids.length === 0;114                  return (115                    <tr key={b.id}>116                      <td className="min-w-[220px]">117                        <Link className="ci-link font-medium" href={`/biomarker/${b.slug}`}>118                          {b.name}119                        </Link>120                        {b.measurement.aliases?.length ? <span className="block text-[11.5px] text-ink-3">{b.measurement.aliases.slice(0, 4).join(' · ')}</span> : null}121                      </td>122                      <td>123                        <Badge tone="outline">{kindLabel(b.kind)}</Badge>124                      </td>125                      <td className="ci-mono text-[12px]">126                        {b.gene_symbols.length ? (127                          b.gene_symbols.map((s, i) => (128                            <span key={s}>129                              {i > 0 ? ', ' : ''}130                              <Link className="ci-link" href={`/gene/${s}`}>131                                {s}132                              </Link>133                            </span>134                          ))135                        ) : (136                          <span className="font-sans text-ink-3" title={b.measurement.notes ?? 'No anchor gene'}>137                            none138                          </span>139                        )}140                        {b.variant_ids.length ? <span className="block font-sans text-[11px] text-ink-3">{b.variant_ids.length} variant anchor{b.variant_ids.length === 1 ? '' : 's'}</span> : null}141                      </td>142                      <td className="ci-mono text-[12px]">143                        {b.ncit_code ? (144                          <a className="ci-link" href={`https://evsexplore.semantics.cancer.gov/evsexplore/concept/ncit/${b.ncit_code}`} target="_blank" rel="noopener noreferrer" title={b.measurement.ncit?.name ?? undefined}>145                            {b.ncit_code}146                          </a>147                        ) : (148                          '—'149                        )}150                      </td>151                      <td className="num">{noScope ? <span className="text-ink-4" title="No gene or variant anchor: not computed">—</span> : fmtInt(b.cancers_n)}</td>152                      <td className="num">{noScope ? <span className="text-ink-4">—</span> : fmtInt(b.drugs_n)}</td>153                      <td className="num">{fmtInt(b.approvals_n)}</td>154                      <td>155                        {b.tumor_agnostic_n > 0 ? (156                          <Badge tone="accent" title={`${b.tumor_agnostic_n} approval row${b.tumor_agnostic_n === 1 ? '' : 's'} flagged tumor_agnostic by the source`}>157                            Tumor-agnostic · {b.tumor_agnostic_n}158                          </Badge>159                        ) : b.measurement.tumorAgnostic ? (160                          <span className="text-[11.5px] text-ink-3" title="Curated flag set, but no approval row with tumor_agnostic = true is ingested for this marker yet">161                            curated · no row yet162                          </span>163                        ) : (164                          <span className="text-ink-4">—</span>165                        )}166                      </td>167                      <td className="num">{noScope ? <span className="text-ink-4">—</span> : fmtInt(b.active_trials_n)}</td>168                    </tr>169                  );170                })}171              </tbody>172            </table>173          </div>174          <Freshness dataUpdatedAt={latest} sourceVersion={verification ? `NCIt ${verification.ncitVersion}` : null} extra={verification ? `codes verified ${verification.verifiedAt}` : undefined} />175          <div className="mt-4">176            <Note>177              Counts are computed over ingested sources only (CIViC, ClinicalTrials.gov, openFDA, Health Canada, PubMed) and say nothing about biomarkers or links that are not yet ingested. A trial counted here tests a linked drug in a linked cancer; it does not necessarily select patients on the biomarker.178            </Note>179          </div>180        </>181      )}182    </div>183  );184}185