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%
15.9 KB · 310 lines tsx
Raw Blame History
1import Link from 'next/link';2import { TOP_LEVEL_CANCERS } from '@cancerindex/ontology';3import { CommandPalette } from '@/components/layout/command-palette';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { Section } from '@/components/ui/section';7import { Badge, ClaimBadge, ConfidenceBadge, StatusBadge } from '@/components/ui/badge';8import { getSiteCounts, getRareSpotlight } from '@/lib/queries/stats';9import { previewSnapshot } from '@/lib/queries/rankings';10import { mostCuratedEvidence } from '@/lib/queries/trials';11import { resolveTopLevel } from '@/lib/queries/cancers';12import { listSources } from '@/lib/queries/sources';13import { fmtInt, fmtValue, fmtDate, scopeLabel, unitLabel, humanize, parseScopeKey } from '@/lib/format';14import { BurdenModule } from '@/components/home/burden-module';15import { RisingModule } from '@/components/home/rising-module';16import { GapsModule } from '@/components/home/gaps-module';17import { TrialIntelModule } from '@/components/home/trial-intel-module';18import { TrialMapModule } from '@/components/home/trial-map-module';19import { GraphModule } from '@/components/home/graph-module';20import { ExplorerModule } from '@/components/home/explorer-module';21import { ApprovalsModule } from '@/components/home/approvals-module';2223export const revalidate = 900;2425export default async function HomePage() {26  const [counts, spotlight, preview, curated, sources] = await Promise.all([27    getSiteCounts(),28    getRareSpotlight(),29    previewSnapshot(['mortality_count', 'incidence_count', 'as_mortality_rate', 'active_trials', 'publications_5y', 'curated_evidence_items']),30    mostCuratedEvidence(8),31    listSources(),32  ]);33  const topLevel = await resolveTopLevel(TOP_LEVEL_CANCERS.map((t) => t.ncit));34  const topMatched = TOP_LEVEL_CANCERS.filter((t) => topLevel.has(t.ncit)).length;3536  const ticker: Array<{ label: string; value: number; note: string; href: string }> = [37    { label: 'Cancers indexed', value: counts.cancers, note: 'active malignant disease entities (NCIt)', href: '/cancers?malignant=1' },38    { label: 'Active oncology trials', value: counts.activeTrials, note: 'interventional, recruiting or active (ClinicalTrials.gov)', href: '/trials?status=active' },39    { label: 'Recruiting Phase III', value: counts.recruitingPhase3, note: 'interventional Phase III studies recruiting now', href: '/trials?status=RECRUITING&phase=PHASE3' },40    { label: 'Approved oncology drugs', value: counts.approvedDrugs, note: `drugs with ≥ 1 approval record (${fmtInt(counts.approvals)} records, all authorities)`, href: '/approvals' },41    { label: 'Genes', value: counts.genes, note: 'HGNC gene records', href: '/genes' },42    { label: 'Publications', value: counts.publications, note: 'PubMed records linked', href: '/search' },43    { label: 'Sources', value: counts.sources, note: `${counts.activeSources} active connector${counts.activeSources === 1 ? '' : 's'}`, href: '/sources' },44  ];4546  return (47    <div className="pb-8">48      {/* Hero */}49      <section className="pt-10 sm:pt-16" aria-labelledby="hero-title">50        <p className="ci-kicker mb-3">Global cancer intelligence</p>51        <h1 id="hero-title" className="max-w-4xl text-4xl leading-[1.05] sm:text-6xl">52          Understand cancer <span className="italic text-ink-2">through data.</span>53        </h1>54        <p className="mt-4 max-w-2xl text-[16px] leading-relaxed text-ink-2">55          CancerIndex connects epidemiology, survival, genomics, treatments, clinical trials, research and regulatory decisions across every recognized cancer entity — with a source, a date and a formula version on every number.56        </p>57        <div className="mt-6 max-w-2xl">58          <CommandPalette variant="hero" />59          <p className="mt-2 text-[12.5px] text-ink-3">60            Try <Link className="ci-link" href="/search?q=glioma">glioma</Link>, <Link className="ci-link" href="/search?q=KRAS">KRAS</Link>, <Link className="ci-link" href="/search?q=osimertinib">osimertinib</Link> or an NCT number · or open the <Link className="ci-link" href="/explore">Data explorer</Link>, the <Link className="ci-link" href="/trials/intelligence">trial intelligence</Link> table, the <Link className="ci-link" href="/graph">knowledge graph</Link> or <Link className="ci-link" href="/pulse">what changed this month</Link>.61          </p>62        </div>63      </section>6465      {/* Ticker */}66      <section aria-label="Index counts" className="ci-rule mt-10 pt-4">67        <ul className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4 lg:grid-cols-7">68          {ticker.map((t) => (69            <li key={t.label}>70              <Link href={t.href} className="block no-underline">71                <span className="ci-num block font-display text-2xl text-ink sm:text-3xl">{fmtInt(t.value)}</span>72                <span className="block text-[12.5px] font-medium text-ink-2">{t.label}</span>73                <span className="block text-[11.5px] text-ink-3">{t.note}</span>74              </Link>75            </li>76          ))}77        </ul>78        <Freshness dataUpdatedAt={counts.lastIngestAt} extra="Counts are live database counts, not estimates" />79      </section>8081      <div className="mt-10 grid gap-10 lg:grid-cols-[1.35fr_1fr]">82        <div className="space-y-10">83          {/* Cancer burden (US, latest year) — §105 "Cancer burden today" */}84          <BurdenModule slug="united-states" />8586          {/* Data explorer default chart (SPEC §110) */}87          <ExplorerModule />8889          {/* Clinical trial intelligence (SPEC §10) */}90          <TrialIntelModule limit={8} />9192          {/* New oncology approvals (SPEC §14) */}93          <ApprovalsModule limit={8} />9495          {/* Rankings preview */}96          <Section97            id="rankings"98            kicker={preview && parseScopeKey(preview.snapshot.scope_key).geo !== 'WORLD' ? `Rankings · ${parseScopeKey(preview.snapshot.scope_key).geo}` : 'Global rankings'}99            title={preview ? preview.metric.name : 'Global rankings'}100            description={preview ? `${scopeLabel(preview.snapshot.scope_key)} · formula ${preview.snapshot.formula_version} · generated ${fmtDate(preview.snapshot.generated_at)} · ${fmtInt(preview.snapshot.eligible_entities)} eligible entities` : undefined}101            actions={102              <Link href="/rankings" className="ci-link">103                All rankings →104              </Link>105            }106          >107            {preview ? (108              <>109                {!preview.metric.slug.match(/incidence|mortality|survival/) ? (110                  <p className="mb-2 text-[12.5px] text-ink-3">No epidemiology-based ranking has been computed yet (awaiting licensed burden data). Showing the research-activity ranking instead — it measures registered activity, not disease burden.</p>111                ) : null}112                <div className="ci-table-wrap">113                  <table className="ci-table">114                    <thead>115                      <tr>116                        <th className="num">#</th>117                        <th>Cancer</th>118                        <th className="num">119                          {preview.metric.name} ({unitLabel(preview.metric.unit)})120                        </th>121                        <th>Confidence</th>122                      </tr>123                    </thead>124                    <tbody>125                      {preview.rows.map((r) => (126                        <tr key={r.id}>127                          <td className="num">{r.rank}</td>128                          <td>129                            <Link className="ci-link" href={`/cancer/${r.slug}`}>130                              {r.canonical_name}131                            </Link>132                          </td>133                          <td className="num">{fmtValue(r.value, r.unit)}</td>134                          <td>135                            <ConfidenceBadge level={r.confidence} />136                          </td>137                        </tr>138                      ))}139                    </tbody>140                  </table>141                </div>142                <div className="mt-2 flex items-center gap-2">143                  <ClaimBadge kind="computed" />144                  <Link href={`/rankings/${preview.metric.slug}?scope=${encodeURIComponent(preview.snapshot.scope_key)}`} className="ci-link text-[13px]">145                    Full table, methodology and "Why this rank?" →146                  </Link>147                </div>148                <Freshness dataUpdatedAt={preview.snapshot.generated_at} extra={`sources: ${preview.snapshot.source_ids.join(', ') || 'see methodology'}`} />149              </>150            ) : (151              <EmptyState title="Rankings not yet computed">152                Ranking snapshots are produced by the ranking engine once counters and licensed epidemiology observations are available. Nothing is shown until a snapshot exists with a scope, a formula version and an inputs hash.153                <div className="mt-1">154                  <Link href="/methodology" className="ci-link">155                    Read how rankings are computed156                  </Link>157                </div>158              </EmptyState>159            )}160          </Section>161162          {/* Fastest-rising incidence (derived on the fly, §105/§328) */}163          <RisingModule slug="united-states" />164165          {/* Largest trial / research gaps (§105, §265-266) */}166          <GapsModule geo="USA" />167168          {/* Curated evidence */}169          <Section id="curated" kicker="Molecular knowledge" title="Most curated molecular evidence" description="Accepted CIViC evidence items whose disease maps to the cancer or its descendants.">170            {curated.length ? (171              <>172                <div className="ci-table-wrap">173                  <table className="ci-table">174                    <thead>175                      <tr>176                        <th>Cancer</th>177                        <th className="num">Evidence items (count)</th>178                        <th className="num">Genes (count)</th>179                      </tr>180                    </thead>181                    <tbody>182                      {curated.map((r) => (183                        <tr key={r.slug}>184                          <td>185                            <Link className="ci-link" href={`/cancer/${r.slug}/evidence`}>186                              {r.canonical_name}187                            </Link>188                          </td>189                          <td className="num">{fmtInt(r.evidence_count)}</td>190                          <td className="num">{fmtInt(r.gene_count)}</td>191                        </tr>192                      ))}193                    </tbody>194                  </table>195                </div>196                <Freshness dataUpdatedAt={curated[0]?.computed_at} extra="source: civic" />197              </>198            ) : (199              <EmptyState>No curated evidence items have been ingested yet. CIViC evidence will appear here grouped by cancer, with native evidence levels and directions.</EmptyState>200            )}201          </Section>202        </div>203204        <aside className="space-y-10">205          {/* Where trials recruit (SPEC §11) */}206          <TrialMapModule topN={8} />207208          {/* Knowledge graph teaser (SPEC §18) */}209          <GraphModule limit={3} />210211          {/* Taxonomy at a glance */}212          <Section213            id="taxonomy"214            kicker="Taxonomy at a glance"215            title="Top-level cancers"216            description={`${topMatched} of ${TOP_LEVEL_CANCERS.length} registry site groups are currently resolved to an indexed entity through their NCIt anchor code.`}217            actions={218              <Link href="/taxonomy" className="ci-link">219                Tree browser →220              </Link>221            }222          >223            <ul className="columns-1 gap-6 text-[13.5px] sm:columns-2">224              {TOP_LEVEL_CANCERS.map((t) => {225                const m = topLevel.get(t.ncit);226                return (227                  <li key={t.key} className="flex items-baseline justify-between gap-2 border-b border-rule py-1">228                    {m ? (229                      <Link href={`/cancer/${m.slug}`} className="ci-link truncate">230                        {t.name}231                      </Link>232                    ) : (233                      <span className="truncate text-ink-3" title={`NCIt ${t.ncit} not yet indexed`}>234                        {t.name}235                      </span>236                    )}237                    <span className="ci-num shrink-0 text-[11.5px] text-ink-3">{m ? (m.descendant_count != null ? `${fmtInt(m.descendant_count)} desc.` : <span className="ci-mono">{t.ncit}</span>) : 'pending'}</span>238                  </li>239                );240              })}241            </ul>242            <p className="mt-2 text-[11.5px] text-ink-3">Site groups follow GLOBOCAN / ICD-10 ranges so burden estimates attach without double counting. "pending" = NCIt concept not yet ingested.</p>243          </Section>244245          {/* Rare spotlight */}246          <Section id="spotlight" kicker="Rare cancer spotlight" title={spotlight ? spotlight.canonicalName : 'Spotlight'} description="A deterministic daily pick among indexed malignant entities, chosen to surface the long tail of the taxonomy rather than the thirty most common cancers.">247            {spotlight ? (248              <div className="text-[13.5px]">249                <div className="flex flex-wrap items-center gap-1.5">250                  <Badge>{humanize(spotlight.entityType)}</Badge>251                  {spotlight.primaryOncotreeCode ? <Badge mono>OncoTree {spotlight.primaryOncotreeCode}</Badge> : null}252                  {spotlight.rareCancer === true ? <Badge tone="accent">Rare</Badge> : null}253                </div>254                {spotlight.parentName ? (255                  <p className="mt-2 text-ink-2">256                    Classified under{' '}257                    <Link className="ci-link" href={`/cancer/${spotlight.parentSlug}`}>258                      {spotlight.parentName}259                    </Link>260                    .261                  </p>262                ) : null}263                <p className="mt-1 text-ink-3">{spotlight.rareCancer == null ? 'Rarity status unknown: no incidence observation is attached yet.' : null}</p>264                <Link href={`/cancer/${spotlight.slug}`} className="ci-link mt-2 inline-block">265                  Open entity →266                </Link>267              </div>268            ) : (269              <EmptyState compact>No malignant entity indexed yet.</EmptyState>270            )}271          </Section>272273          {/* Sources */}274          <Section275            id="sources"276            kicker="Sources"277            title="Source registry"278            actions={279              <Link href="/sources" className="ci-link">280                Coverage matrix →281              </Link>282            }283          >284            {sources.length ? (285              <ul className="divide-y divide-rule text-[13.5px]">286                {sources.map((s) => (287                  <li key={s.slug} className="flex flex-wrap items-center justify-between gap-2 py-1.5">288                    <Link className="ci-link" href={`/source/${s.slug}`}>289                      {s.name}290                    </Link>291                    <span className="flex items-center gap-1.5">292                      <StatusBadge status={s.status} />293                      <Badge tone={s.license_status === 'approved' ? 'ok' : s.license_status === 'blocked' ? 'danger' : 'warn'} title="License review status">294                        license: {s.license_status}295                      </Badge>296                    </span>297                  </li>298                ))}299              </ul>300            ) : (301              <EmptyState compact>No source registered.</EmptyState>302            )}303            <Freshness dataUpdatedAt={sources.reduce<Date | null>((m, s) => (s.last_run_finished_at && (!m || s.last_run_finished_at > m) ? s.last_run_finished_at : m), null)} extra="License status is reviewed before any connector goes live" />304          </Section>305        </aside>306      </div>307    </div>308  );309}310