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.3 KB · 194 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader } from '@/components/ui/section';4import { Badge, ConfidenceBadge } from '@/components/ui/badge';5import { CompletenessDots } from '@/components/ui/completeness';6import { EmptyState } from '@/components/ui/empty-state';7import { Pagination } from '@/components/ui/pagination';8import { explorerCount, explorerRows, listAnatomicalSites, ENTITY_TYPES, SORTS, type ExplorerFilters, type ExplorerRow } from '@/lib/queries/cancers';9import { fmtInt, humanize } from '@/lib/format';10import { str, int, oneOf, bool, withParams, type SP } from '@/lib/search-params';1112export const metadata: Metadata = { title: 'Cancers', description: 'Explore every indexed cancer entity with filters for type, anatomical site, hematologic and pediatric relevance.' };13// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages.14export const revalidate = 600;1516const PAGE_SIZE = 50;1718function dataConfidence(r: ExplorerRow): string {19  const domains = [r.epidemiology_obs_count, r.survival_obs_count, r.gene_count, r.evidence_count, r.drug_count, r.active_trial_count, r.publication_count_5y].filter((n) => (n ?? 0) > 0).length;20  if (domains >= 5) return 'HIGH';21  if (domains >= 3) return 'MEDIUM';22  if (domains >= 1) return 'LOW';23  return 'INSUFFICIENT_DATA';24}2526export default async function CancersPage({ searchParams }: { searchParams: Promise<SP> }) {27  const sp = await searchParams;28  const f: ExplorerFilters = {29    q: str(sp, 'q'),30    level: oneOf(sp, 'level', ['top', 'all'] as const, 'all'),31    entityType: ENTITY_TYPES.includes(str(sp, 'entity_type') as (typeof ENTITY_TYPES)[number]) ? str(sp, 'entity_type') : '',32    malignant: bool(sp, 'malignant'),33    hematologic: bool(sp, 'hematologic'),34    pediatric: bool(sp, 'pediatric'),35    site: str(sp, 'site'),36    sort: oneOf(sp, 'sort', SORTS, 'name'),37    page: int(sp, 'page', 1, 1, 10_000),38    pageSize: PAGE_SIZE,39  };40  const [total, rows, sites] = await Promise.all([explorerCount(f), explorerRows(f), listAnatomicalSites()]);41  const current = { q: f.q, level: f.level, entity_type: f.entityType, malignant: f.malignant == null ? '' : f.malignant ? '1' : '0', hematologic: f.hematologic == null ? '' : f.hematologic ? '1' : '0', pediatric: f.pediatric == null ? '' : f.pediatric ? '1' : '0', site: f.site, sort: f.sort };42  const href = (o: Record<string, string | number | null | undefined>) => `/cancers${withParams({ ...current, level: f.level === 'all' ? '' : f.level }, o)}`;4344  return (45    <div>46      <PageHeader kicker="Explorer" title="Cancers" lede="Every indexed disease entity from the canonical taxonomy. Counters aggregate over each entity's descendants; completeness dots show which data domains hold at least one record." />4748      <form method="get" action="/cancers" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_1fr_auto]">49        <label className="flex flex-col gap-1">50          <span className="ci-kicker">Name, alias or code</span>51          <input name="q" defaultValue={f.q} placeholder="e.g. glioblastoma, NSCLC, C4914" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />52        </label>53        <label className="flex flex-col gap-1">54          <span className="ci-kicker">Level</span>55          <select name="level" defaultValue={f.level} className="border border-rule-strong bg-white px-2 py-1.5">56            <option value="all">All entities</option>57            <option value="top">Top-level (ranking set)</option>58          </select>59        </label>60        <label className="flex flex-col gap-1">61          <span className="ci-kicker">Entity type</span>62          <select name="entity_type" defaultValue={f.entityType} className="border border-rule-strong bg-white px-2 py-1.5">63            <option value="">Any</option>64            {ENTITY_TYPES.map((t) => (65              <option key={t} value={t}>66                {humanize(t)}67              </option>68            ))}69          </select>70        </label>71        <label className="flex flex-col gap-1">72          <span className="ci-kicker">Anatomical site</span>73          <select name="site" defaultValue={f.site} className="border border-rule-strong bg-white px-2 py-1.5">74            <option value="">Any</option>75            {sites.map((s) => (76              <option key={s.slug} value={s.slug}>77                {s.name} ({s.n})78              </option>79            ))}80          </select>81        </label>82        <label className="flex flex-col gap-1">83          <span className="ci-kicker">Sort</span>84          <select name="sort" defaultValue={f.sort} className="border border-rule-strong bg-white px-2 py-1.5">85            <option value="name">Name</option>86            <option value="trials">Active trials</option>87            <option value="publications">Publications (5y)</option>88            <option value="evidence">Evidence items</option>89            <option value="descendants">Descendants</option>90          </select>91        </label>92        <div className="flex flex-col justify-end gap-1">93          <div className="flex flex-wrap gap-x-3 gap-y-1 text-[12.5px]">94            <label className="inline-flex items-center gap-1">95              <input type="checkbox" name="malignant" value="1" defaultChecked={f.malignant === true} /> Malignant96            </label>97            <label className="inline-flex items-center gap-1">98              <input type="checkbox" name="hematologic" value="1" defaultChecked={f.hematologic === true} /> Hematologic99            </label>100            <label className="inline-flex items-center gap-1">101              <input type="checkbox" name="pediatric" value="1" defaultChecked={f.pediatric === true} /> Pediatric102            </label>103          </div>104          <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">105            Apply106          </button>107        </div>108      </form>109110      <p className="mt-3 text-[13px] text-ink-2">111        <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> {total === 1 ? 'entity' : 'entities'}112        {f.q ? (113          <>114            {' '}115            matching <q>{f.q}</q>116          </>117        ) : null}118        {f.level === 'top' && total === 0 ? <span className="text-ink-3"> — the top-level flag is assigned when the NCIt anchor concepts are ingested; until then browse all entities.</span> : null}119      </p>120121      {rows.length === 0 ? (122        <div className="mt-3">123          <EmptyState title="No entity matches these filters" knows={[{ label: 'Browse all entities', href: '/cancers' }, { label: 'Taxonomy tree', href: '/taxonomy' }]} />124        </div>125      ) : (126        <>127          <div className="ci-table-wrap mt-3">128            <table className="ci-table">129              <thead>130                <tr>131                  <th className="sticky-col">Cancer</th>132                  <th>Type</th>133                  <th>Parent(s)</th>134                  <th className="num">Active trials (count)</th>135                  <th className="num">Publications 5y (count)</th>136                  <th className="num">Evidence items (count)</th>137                  <th className="num">Children</th>138                  <th>Completeness</th>139                  <th>Data confidence</th>140                </tr>141              </thead>142              <tbody>143                {rows.map((r) => (144                  <tr key={r.id}>145                    <td className="sticky-col min-w-[220px]">146                      <Link href={`/cancer/${r.slug}`} className="ci-link font-medium">147                        {r.canonical_name}148                      </Link>149                      <span className="ci-mono ml-2 text-[10.5px] text-ink-4">{r.id}</span>150                      <span className="mt-0.5 flex flex-wrap gap-1">151                        {r.hematologic ? <Badge>Hematologic</Badge> : null}152                        {r.pediatric_relevant ? <Badge>Pediatric</Badge> : null}153                        {r.rare_cancer === true ? <Badge tone="accent">Rare</Badge> : null}154                        {!r.malignant ? <Badge tone="outline">Non-malignant / precursor</Badge> : null}155                      </span>156                    </td>157                    <td>158                      <Badge>{humanize(r.entity_type)}</Badge>159                    </td>160                    <td className="max-w-[260px] text-[12.5px] text-ink-2">161                      {r.parents?.length162                        ? r.parents.map((p, i) => (163                            <span key={p.slug}>164                              {i > 0 ? ', ' : ''}165                              <Link className="ci-link" href={`/cancer/${p.slug}`}>166                                {p.name}167                              </Link>168                            </span>169                          ))170                        : <span className="text-ink-4">root</span>}171                    </td>172                    <td className="num">{r.active_trial_count == null ? <span className="text-ink-4">—</span> : fmtInt(r.active_trial_count)}</td>173                    <td className="num">{r.publication_count_5y == null ? <span className="text-ink-4">—</span> : fmtInt(r.publication_count_5y)}</td>174                    <td className="num">{r.evidence_count == null ? <span className="text-ink-4">—</span> : fmtInt(r.evidence_count)}</td>175                    <td className="num">{fmtInt(r.child_count)}</td>176                    <td>177                      <CompletenessDots filled={{ taxonomy: true, epidemiology: r.epidemiology_obs_count ?? 0, survival: r.survival_obs_count ?? 0, genomics: r.gene_count ?? 0, evidence: r.evidence_count ?? 0, drugs: r.drug_count ?? 0, trials: r.active_trial_count ?? 0, literature: r.publication_count_5y ?? 0 }} />178                    </td>179                    <td>180                      <ConfidenceBadge level={dataConfidence(r)} />181                    </td>182                  </tr>183                ))}184              </tbody>185            </table>186          </div>187          <Pagination page={f.page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} />188          <p className="mt-2 text-[11.5px] text-ink-3">"—" means no counter has been computed for this entity yet (counters appear after the first connector of that domain runs). Data confidence summarizes how many domains hold data; it is not a clinical judgement.</p>189        </>190      )}191    </div>192  );193}194