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%
6.3 KB · 122 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import { PageHeader } from '@/components/ui/section';3import { EmptyState } from '@/components/ui/empty-state';4import { Pager } from '@/components/ui/pager';5import { TrialTable } from '@/components/data/trial-list';6import { Freshness } from '@/components/ui/freshness';7import { listTrialRows, trialFacets, TRIAL_PAGE_SIZE } from '@/lib/queries/trials';8import { getCancerBySlug, getDescendantIds } from '@/lib/queries/cancers';9import { fmtInt, humanize, phaseLabel } from '@/lib/format';10import { pageInfo } from '@/lib/pagination';11import { str, int, withParams, type SP } from '@/lib/search-params';1213export const metadata: Metadata = { title: 'Clinical trials', description: 'ClinicalTrials.gov oncology studies mapped to the cancer taxonomy, filterable by status, phase, country and cancer.' };14// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages.15export const revalidate = 600;1617export default async function TrialsPage({ searchParams }: { searchParams: Promise<SP> }) {18  const sp = await searchParams;19  const q = str(sp, 'q').slice(0, 200);20  const status = str(sp, 'status');21  const phase = str(sp, 'phase');22  const country = str(sp, 'country');23  const cancerSlug = str(sp, 'cancer');24  const page = int(sp, 'page', 1, 1, 100_000);25  const cancer = cancerSlug ? await getCancerBySlug(cancerSlug) : null;26  const cancerIds = cancer ? await getDescendantIds(cancer.id) : null;27  const [facets, { rows, total }] = await Promise.all([trialFacets(cancerIds), listTrialRows({ q, status, phase, country, cancerIds, page, pageSize: TRIAL_PAGE_SIZE })]);28  const info = pageInfo(page, TRIAL_PAGE_SIZE, total);29  const current = { q, status, phase, country, cancer: cancerSlug };30  const href = (o: Record<string, string | number | null | undefined>) => `/trials${withParams(current, o)}`;31  const anyTrials = facets.statuses.reduce((s, x) => s + x.n, 0);32  const filtered = Boolean(q || status || phase || country || cancer);3334  return (35    <div>36      <PageHeader kicker="Clinical trials" title="Clinical trials" lede="Studies registered on ClinicalTrials.gov whose conditions were reconciled to the cancer taxonomy. Status and phase are as posted by the registrant." />37      <form method="get" action="/trials" 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]" role="search" aria-label="Filter trials">38        <label className="flex flex-col gap-1">39          <span className="ci-kicker">NCT, title, acronym or sponsor</span>40          <input name="q" defaultValue={q} className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />41        </label>42        <label className="flex flex-col gap-1">43          <span className="ci-kicker">Status</span>44          <select name="status" defaultValue={status} className="border border-rule-strong bg-white px-2 py-1.5">45            <option value="">Any</option>46            <option value="active">Active (any)</option>47            {facets.statuses.map((s) => (48              <option key={s.k} value={s.k}>49                {humanize(s.k)} ({s.n})50              </option>51            ))}52          </select>53        </label>54        <label className="flex flex-col gap-1">55          <span className="ci-kicker">Phase</span>56          <select name="phase" defaultValue={phase} className="border border-rule-strong bg-white px-2 py-1.5">57            <option value="">Any</option>58            {facets.phases.map((p) => (59              <option key={p.k} value={p.k}>60                {phaseLabel(p.k)} ({p.n})61              </option>62            ))}63          </select>64        </label>65        <label className="flex flex-col gap-1">66          <span className="ci-kicker">Country</span>67          <select name="country" defaultValue={country} className="border border-rule-strong bg-white px-2 py-1.5">68            <option value="">Any</option>69            {facets.countries.map((c) => (70              <option key={c.k} value={c.k}>71                {c.k} ({c.n})72              </option>73            ))}74          </select>75        </label>76        <label className="flex flex-col gap-1">77          <span className="ci-kicker">Cancer (slug)</span>78          <input name="cancer" defaultValue={cancerSlug} placeholder="e.g. glioblastoma" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />79        </label>80        <div className="flex items-end">81          <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">82            Apply83          </button>84        </div>85      </form>86      <p className="mt-3 text-[13px] text-ink-2" role="status">87        <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> studies{filtered ? ' match' : ' indexed'}88        {cancer ? (89          <>90            {' '}91            · mapped to <span className="font-medium">{cancer.canonical_name}</span> and descendants92          </>93        ) : cancerSlug ? (94          <span className="text-warn"> — unknown cancer slug "{cancerSlug}" (ignored)</span>95        ) : null}96      </p>97      {rows.length === 0 ? (98        <div className="mt-3">99          <EmptyState title={anyTrials === 0 ? 'Trials not yet available' : 'No study matches these filters'} knows={[{ label: 'Cancers explorer', href: '/cancers' }, { label: 'Sources', href: '/sources' }]}>100            {anyTrials === 0 ? 'The ClinicalTrials.gov connector has not run on this environment. Studies appear here once ingested and their conditions reconciled to the taxonomy.' : 'Relax a filter or clear the search.'}101          </EmptyState>102        </div>103      ) : (104        <>105          <div className="mt-3">106            <TrialTable107              rows={rows}108              summary={109                <>110                  Showing {fmtInt(info.from)}–{fmtInt(info.to)} of {fmtInt(total)} studies111                </>112              }113            />114          </div>115          <Pager page={info.page} pageSize={TRIAL_PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} label="Trial pages" noun="studies" />116          <Freshness dataUpdatedAt={rows.reduce<Date | string | null>((m, t) => (m == null || String(t.updated_at) > String(m) ? t.updated_at : m), null)} extra="source: clinicaltrials" />117        </>118      )}119    </div>120  );121}122