Country pages: clinical trial activity (sites by cancer and phase from trial_site_country_counts) and jurisdiction approval records; pulse link on home
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 changed files +277 −1
modified
apps/web/qa/smoke.mjs
+1 −0
@@ -51,6 +51,7 @@ const ROUTES = [ | ||
| 51 | 51 | { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 }, |
| 52 | 52 | { path: '/methodology/trial-map', expect: ['ISO'] }, |
| 53 | 53 | { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] }, |
| 54 | + { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] }, | |
| 54 | 55 | { path: '/data-updates', expect: ['Data update log', 'ING-'] }, |
| 55 | 56 | { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true }, |
| 56 | 57 | { path: '/api/v1/trials/intelligence?limit=2', expect: ['data'], kind: 'json', optionalLocal: true }, |
modified
apps/web/src/app/country/[slug]/page.tsx
+6 −0
@@ -7,6 +7,7 @@ import { Freshness } from '@/components/ui/freshness'; | ||
| 7 | 7 | import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge'; |
| 8 | 8 | import { SourceBadge } from '@/components/ui/source-badge'; |
| 9 | 9 | import { Breadcrumbs } from '@/components/layout/breadcrumbs'; |
| 10 | +import { CountryActivity } from '@/components/country/activity'; | |
| 10 | 11 | import { TrendChart, type TrendSeries } from '@/components/charts/trend-chart'; |
| 11 | 12 | import { getGeographyBySlug, coverageFor, yearsFor, allSitesObservations, topCancersFor, trendFor, geographyScopeCode, WHO_REGION_LABEL, SEXES, BURDEN_METRICS, type Sex, type TopCancersResult } from '@/lib/queries/geography'; |
| 12 | 13 | import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology'; |
@@ -62,6 +63,9 @@ export default async function CountryPage({ params, searchParams }: { params: Pr | ||
| 62 | 63 | </Link> |
| 63 | 64 | </div> |
| 64 | 65 | </EmptyState> |
| 66 | + <div className="mt-8 space-y-8"> | |
| 67 | + <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} /> | |
| 68 | + </div> | |
| 65 | 69 | </div> |
| 66 | 70 | ); |
| 67 | 71 | } |
@@ -237,6 +241,8 @@ export default async function CountryPage({ params, searchParams }: { params: Pr | ||
| 237 | 241 | )} |
| 238 | 242 | </Section> |
| 239 | 243 | |
| 244 | + <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} /> | |
| 245 | + | |
| 240 | 246 | {/* Sources & freshness */} |
| 241 | 247 | <Section id="sources" kicker="Provenance" title="Sources and freshness" description="What each source covers for this geography, exactly as ingested. Values are normalized (units, labels) but never changed."> |
| 242 | 248 | <div className="ci-table-wrap"> |
modified
apps/web/src/app/page.tsx
+1 −1
@@ -57,7 +57,7 @@ export default async function HomePage() { | ||
| 57 | 57 | <div className="mt-6 max-w-2xl"> |
| 58 | 58 | <CommandPalette variant="hero" /> |
| 59 | 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 or the <Link className="ci-link" href="/graph">knowledge graph</Link>. | |
| 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 | 61 | </p> |
| 62 | 62 | </div> |
| 63 | 63 | </section> |
added
apps/web/src/components/country/activity.tsx
+157 −0
@@ -0,0 +1,157 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { Section, Note } from '@/components/ui/section'; | |
| 3 | +import { EmptyState } from '@/components/ui/empty-state'; | |
| 4 | +import { Freshness } from '@/components/ui/freshness'; | |
| 5 | +import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge'; | |
| 6 | +import { SourceBadge } from '@/components/ui/source-badge'; | |
| 7 | +import { countryTrialTotals, countrySitesByCancer, countrySitesByPhase, jurisdictionApprovalSummary, latestJurisdictionApprovals } from '@/lib/queries/country-activity'; | |
| 8 | +import { fmtDate, fmtInt, fmtPct, phaseLabel, truncate } from '@/lib/format'; | |
| 9 | + | |
| 10 | +/** ISO 3166-1 alpha-2 codes that are also `drug_approvals.jurisdiction` codes. */ | |
| 11 | +const JURISDICTIONS: Record<string, string> = { US: 'US', CA: 'CA', GB: 'UK', AU: 'AU', JP: 'JP', CH: 'CH' }; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Country research & regulatory activity (SPEC §22): trial sites in the country from ClinicalTrials.gov | |
| 15 | + * locations (derived, formula ci-trial-sites-v1) and approval records for the country's regulator. | |
| 16 | + * Rendered on every country page, including those without epidemiology observations. | |
| 17 | + */ | |
| 18 | +export async function CountryActivity({ iso2, iso3, name, kind }: { iso2: string | null; iso3: string | null; name: string; kind: string }) { | |
| 19 | + if (kind !== 'country' || !iso3) return null; | |
| 20 | + const jurisdiction = iso2 ? JURISDICTIONS[iso2] ?? null : null; | |
| 21 | + const [totals, byCancer, byPhase, approvalSummary, latest] = await Promise.all([countryTrialTotals(iso3), countrySitesByCancer(iso3, 25), countrySitesByPhase(iso3), jurisdiction ? jurisdictionApprovalSummary(jurisdiction) : Promise.resolve([]), jurisdiction ? latestJurisdictionApprovals(jurisdiction, 10) : Promise.resolve([])]); | |
| 22 | + | |
| 23 | + return ( | |
| 24 | + <> | |
| 25 | + <Section id="trial-activity" kicker="Clinical research" title={`Clinical trial activity in ${name}`} description="Registrant-entered study locations on ClinicalTrials.gov mapped to this country (any study type, any phase). A site is one location; a multi-site study weighs by its number of sites. Recruiting = location status RECRUITING, or the study status when the site has none." actions={<Link href={`/trials/map?recruiting=1`} className="ci-link">Trial map →</Link>}> | |
| 26 | + {totals ? ( | |
| 27 | + <> | |
| 28 | + <dl className="mb-3 grid grid-cols-2 gap-x-6 gap-y-2 text-[13.5px] sm:grid-cols-4"> | |
| 29 | + {[ | |
| 30 | + ['Sites', totals.sites], | |
| 31 | + ['Studies with a site here', totals.trials], | |
| 32 | + ['Recruiting sites', totals.recruiting_sites], | |
| 33 | + ['Recruiting studies', totals.recruiting_trials], | |
| 34 | + ].map(([k, v]) => ( | |
| 35 | + <div key={String(k)}> | |
| 36 | + <dt className="ci-kicker">{k}</dt> | |
| 37 | + <dd className="ci-num text-left font-display text-2xl">{fmtInt(v as number)}</dd> | |
| 38 | + </div> | |
| 39 | + ))} | |
| 40 | + </dl> | |
| 41 | + <div className="grid gap-6 lg:grid-cols-[3fr_2fr]"> | |
| 42 | + <div className="ci-table-wrap"> | |
| 43 | + <table className="ci-table"> | |
| 44 | + <caption className="pb-1 text-[12.5px] text-ink-3">Top-level cancers by sites in {name} (descendants included)</caption> | |
| 45 | + <thead> | |
| 46 | + <tr> | |
| 47 | + <th>Cancer</th> | |
| 48 | + <th className="num">Sites</th> | |
| 49 | + <th className="num">Studies</th> | |
| 50 | + <th className="num">Recruiting studies</th> | |
| 51 | + <th className="num">Share of sites</th> | |
| 52 | + </tr> | |
| 53 | + </thead> | |
| 54 | + <tbody> | |
| 55 | + {byCancer.map((r) => ( | |
| 56 | + <tr key={r.cancer_id}> | |
| 57 | + <td> | |
| 58 | + <Link href={`/cancer/${r.slug}/trials`} className="ci-link"> | |
| 59 | + {r.canonical_name} | |
| 60 | + </Link> | |
| 61 | + </td> | |
| 62 | + <td className="num">{fmtInt(r.sites)}</td> | |
| 63 | + <td className="num">{fmtInt(r.trials)}</td> | |
| 64 | + <td className="num">{fmtInt(r.recruiting_trials)}</td> | |
| 65 | + <td className="num text-ink-3">{fmtPct(r.sites / totals.sites, 1)}</td> | |
| 66 | + </tr> | |
| 67 | + ))} | |
| 68 | + </tbody> | |
| 69 | + </table> | |
| 70 | + </div> | |
| 71 | + <div className="ci-table-wrap"> | |
| 72 | + <table className="ci-table"> | |
| 73 | + <caption className="pb-1 text-[12.5px] text-ink-3">By phase (a Phase II/III study counts in both)</caption> | |
| 74 | + <thead> | |
| 75 | + <tr> | |
| 76 | + <th>Phase</th> | |
| 77 | + <th className="num">Sites</th> | |
| 78 | + <th className="num">Studies</th> | |
| 79 | + <th className="num">Recruiting</th> | |
| 80 | + </tr> | |
| 81 | + </thead> | |
| 82 | + <tbody> | |
| 83 | + {byPhase.map((p) => ( | |
| 84 | + <tr key={p.phase}> | |
| 85 | + <td> | |
| 86 | + <Link href={`/trials/map?phase=${p.phase}`} className="ci-link"> | |
| 87 | + {phaseLabel(p.phase)} | |
| 88 | + </Link> | |
| 89 | + </td> | |
| 90 | + <td className="num">{fmtInt(p.sites)}</td> | |
| 91 | + <td className="num">{fmtInt(p.trials)}</td> | |
| 92 | + <td className="num">{fmtInt(p.recruiting_trials)}</td> | |
| 93 | + </tr> | |
| 94 | + ))} | |
| 95 | + </tbody> | |
| 96 | + </table> | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + <p className="mt-2 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3"> | |
| 100 | + <ClaimBadge kind="computed" /> <span>formula ci-trial-sites-v1 · source: clinicaltrials · shares are of sites, not of studies</span> | |
| 101 | + </p> | |
| 102 | + <Freshness dataUpdatedAt={totals.computed_at} extra="trial_site_country_counts" /> | |
| 103 | + </> | |
| 104 | + ) : ( | |
| 105 | + <EmptyState compact title="No study location mapped to this country">Country aggregates exist for every country named in ClinicalTrials.gov locations; none names {name} yet, or the aggregate has not been computed.</EmptyState> | |
| 106 | + )} | |
| 107 | + </Section> | |
| 108 | + | |
| 109 | + {jurisdiction ? ( | |
| 110 | + <Section id="approvals" kicker="Regulatory" title={`Oncology approval records · ${jurisdiction}`} description="Records of the country's regulator as ingested (one record = one decision for one application or product identifier). Absence of a record here is not evidence of absence." actions={<Link href={`/approvals?jurisdiction=${jurisdiction}`} className="ci-link">All records →</Link>}> | |
| 111 | + {approvalSummary.length ? ( | |
| 112 | + <> | |
| 113 | + <p className="mb-2 flex flex-wrap gap-1.5 text-[12px]"> | |
| 114 | + {approvalSummary.map((s) => ( | |
| 115 | + <Badge key={`${s.authority}-${s.status}`} tone="outline" title={`${s.drugs} distinct drugs · latest dated ${s.latest ?? '—'}`}> | |
| 116 | + {s.authority} · {s.status}: {fmtInt(s.n)} records / {fmtInt(s.drugs)} drugs | |
| 117 | + </Badge> | |
| 118 | + ))} | |
| 119 | + </p> | |
| 120 | + <ul className="divide-y divide-rule text-[13.5px]"> | |
| 121 | + {latest.map((a) => ( | |
| 122 | + <li key={a.id} className="grid gap-x-3 py-1.5 sm:grid-cols-[92px_1fr]"> | |
| 123 | + <span className="ci-mono text-[12px] text-ink-3">{a.approval_date ? fmtDate(a.approval_date) : 'undated'}</span> | |
| 124 | + <span className="min-w-0"> | |
| 125 | + <Link href={`/drug/${a.drug_slug}`} className="ci-link font-medium"> | |
| 126 | + {a.drug_name} | |
| 127 | + </Link>{' '} | |
| 128 | + <StatusBadge status={a.status} />{' '} | |
| 129 | + {a.cancer_slug ? ( | |
| 130 | + <Link href={`/cancer/${a.cancer_slug}`} className="ci-link"> | |
| 131 | + {a.cancer_name} | |
| 132 | + </Link> | |
| 133 | + ) : ( | |
| 134 | + <span className="text-ink-3">cancer not stated in this record</span> | |
| 135 | + )} | |
| 136 | + <span className="mt-0.5 block text-[12.5px] text-ink-2" title={a.indication}> | |
| 137 | + {truncate(a.indication, 140)} | |
| 138 | + </span> | |
| 139 | + <SourceBadge p={{ sourceSlug: a.source_slug }} compact /> | |
| 140 | + </span> | |
| 141 | + </li> | |
| 142 | + ))} | |
| 143 | + </ul> | |
| 144 | + <p className="mt-2"> | |
| 145 | + <ClaimBadge kind="regulatory" /> | |
| 146 | + </p> | |
| 147 | + </> | |
| 148 | + ) : ( | |
| 149 | + <EmptyState compact title="No approval record for this jurisdiction yet">Regulatory connectors are live for the FDA (US), Health Canada (CA) and the EMA (EU). Other regulators are not yet ingested.</EmptyState> | |
| 150 | + )} | |
| 151 | + </Section> | |
| 152 | + ) : ( | |
| 153 | + <Note>Regulatory records are ingested per jurisdiction (US, CA, EU so far); {name} has no connected regulator yet.</Note> | |
| 154 | + )} | |
| 155 | + </> | |
| 156 | + ); | |
| 157 | +} | |
added
apps/web/src/lib/queries/country-activity.ts
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Country-level research and regulatory activity (SPEC §22 "Clinical trial activity", §13 jurisdiction-aware | |
| 6 | + * approvals). Trial sites come from the derived `trial_site_country_counts` (ClinicalTrials.gov locations, | |
| 7 | + * matched to the geography by ISO 3166-1 alpha-3); approvals from `drug_approvals` for the jurisdiction code. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +export interface CountryTrialTotals { | |
| 11 | + sites: number; | |
| 12 | + trials: number; | |
| 13 | + recruiting_sites: number; | |
| 14 | + recruiting_trials: number; | |
| 15 | + computed_at: Date | string | null; | |
| 16 | +} | |
| 17 | +export async function countryTrialTotals(iso3: string): Promise<CountryTrialTotals | null> { | |
| 18 | + const rows = await safe( | |
| 19 | + () => | |
| 20 | + run<CountryTrialTotals>(sql` | |
| 21 | + SELECT coalesce(max(sites) FILTER (WHERE NOT recruiting_only), 0)::int AS sites, coalesce(max(trials) FILTER (WHERE NOT recruiting_only), 0)::int AS trials, | |
| 22 | + coalesce(max(sites) FILTER (WHERE recruiting_only), 0)::int AS recruiting_sites, coalesce(max(trials) FILTER (WHERE recruiting_only), 0)::int AS recruiting_trials, | |
| 23 | + max(updated_at) AS computed_at | |
| 24 | + FROM trial_site_country_counts WHERE iso3 = ${iso3} AND cancer_id IS NULL AND phase IS NULL`), | |
| 25 | + [] as CountryTrialTotals[], | |
| 26 | + ); | |
| 27 | + const r = rows[0]; | |
| 28 | + return r && r.sites > 0 ? r : null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface CountryCancerSites { | |
| 32 | + cancer_id: string; | |
| 33 | + slug: string; | |
| 34 | + canonical_name: string; | |
| 35 | + sites: number; | |
| 36 | + trials: number; | |
| 37 | + recruiting_sites: number; | |
| 38 | + recruiting_trials: number; | |
| 39 | +} | |
| 40 | +/** Sites and studies per top-level cancer in this country (any phase). */ | |
| 41 | +export async function countrySitesByCancer(iso3: string, limit = 40): Promise<CountryCancerSites[]> { | |
| 42 | + return safe( | |
| 43 | + () => | |
| 44 | + run<CountryCancerSites>(sql` | |
| 45 | + SELECT c.id AS cancer_id, c.slug, c.canonical_name, | |
| 46 | + coalesce(max(t.sites) FILTER (WHERE NOT t.recruiting_only), 0)::int AS sites, coalesce(max(t.trials) FILTER (WHERE NOT t.recruiting_only), 0)::int AS trials, | |
| 47 | + coalesce(max(t.sites) FILTER (WHERE t.recruiting_only), 0)::int AS recruiting_sites, coalesce(max(t.trials) FILTER (WHERE t.recruiting_only), 0)::int AS recruiting_trials | |
| 48 | + FROM trial_site_country_counts t JOIN cancers c ON c.id = t.cancer_id | |
| 49 | + WHERE t.iso3 = ${iso3} AND t.phase IS NULL | |
| 50 | + GROUP BY c.id, c.slug, c.canonical_name ORDER BY sites DESC, c.canonical_name LIMIT ${limit}`), | |
| 51 | + [] as CountryCancerSites[], | |
| 52 | + ); | |
| 53 | +} | |
| 54 | + | |
| 55 | +export interface CountryPhaseSites { | |
| 56 | + phase: string; | |
| 57 | + sites: number; | |
| 58 | + trials: number; | |
| 59 | + recruiting_trials: number; | |
| 60 | +} | |
| 61 | +export async function countrySitesByPhase(iso3: string): Promise<CountryPhaseSites[]> { | |
| 62 | + return safe( | |
| 63 | + () => | |
| 64 | + run<CountryPhaseSites>(sql` | |
| 65 | + SELECT phase, coalesce(max(sites) FILTER (WHERE NOT recruiting_only), 0)::int AS sites, coalesce(max(trials) FILTER (WHERE NOT recruiting_only), 0)::int AS trials, | |
| 66 | + coalesce(max(trials) FILTER (WHERE recruiting_only), 0)::int AS recruiting_trials | |
| 67 | + FROM trial_site_country_counts WHERE iso3 = ${iso3} AND cancer_id IS NULL AND phase IS NOT NULL | |
| 68 | + GROUP BY phase ORDER BY phase`), | |
| 69 | + [] as CountryPhaseSites[], | |
| 70 | + ); | |
| 71 | +} | |
| 72 | + | |
| 73 | +export interface JurisdictionApprovalSummary { | |
| 74 | + authority: string; | |
| 75 | + status: string; | |
| 76 | + n: number; | |
| 77 | + drugs: number; | |
| 78 | + latest: string | null; | |
| 79 | +} | |
| 80 | +export async function jurisdictionApprovalSummary(jurisdiction: string): Promise<JurisdictionApprovalSummary[]> { | |
| 81 | + return safe( | |
| 82 | + () => | |
| 83 | + run<JurisdictionApprovalSummary>(sql` | |
| 84 | + SELECT authority, status, count(*)::int AS n, count(DISTINCT drug_id)::int AS drugs, max(approval_date) FILTER (WHERE approval_date ~ '^\\d{4}-\\d{2}-\\d{2}$') AS latest | |
| 85 | + FROM drug_approvals WHERE jurisdiction = ${jurisdiction} GROUP BY authority, status ORDER BY authority, n DESC`), | |
| 86 | + [] as JurisdictionApprovalSummary[], | |
| 87 | + ); | |
| 88 | +} | |
| 89 | + | |
| 90 | +export interface JurisdictionApproval { | |
| 91 | + id: number; | |
| 92 | + approval_date: string | null; | |
| 93 | + authority: string; | |
| 94 | + status: string; | |
| 95 | + indication: string; | |
| 96 | + drug_slug: string; | |
| 97 | + drug_name: string; | |
| 98 | + cancer_slug: string | null; | |
| 99 | + cancer_name: string | null; | |
| 100 | + source_slug: string; | |
| 101 | +} | |
| 102 | +export async function latestJurisdictionApprovals(jurisdiction: string, limit = 12): Promise<JurisdictionApproval[]> { | |
| 103 | + return safe( | |
| 104 | + () => | |
| 105 | + run<JurisdictionApproval>(sql` | |
| 106 | + SELECT a.id, a.approval_date, a.authority, a.status, a.indication, d.slug AS drug_slug, d.name AS drug_name, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug | |
| 107 | + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id | |
| 108 | + WHERE a.jurisdiction = ${jurisdiction} AND a.status IN ('approved','accelerated','conditional') | |
| 109 | + ORDER BY a.approval_date DESC NULLS LAST, a.id DESC LIMIT ${limit}`), | |
| 110 | + [] as JurisdictionApproval[], | |
| 111 | + ); | |
| 112 | +} | |
| 113 | ||