spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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