SPB Git

spb/cancerindex

Public

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>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 4375731 Browse files

5 changed files +277 −1

modified apps/web/qa/smoke.mjs +1 −0
@@ -51,6 +51,7 @@ const ROUTES = [
51 { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 }, 51 { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 },
52 { path: '/methodology/trial-map', expect: ['ISO'] }, 52 { path: '/methodology/trial-map', expect: ['ISO'] },
53 { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] }, 53 { path: '/pulse', expect: ['What changed in cancer', 'Phase III'] },
54+ { path: '/country/canada', expect: ['Clinical trial activity in Canada', 'Oncology approval records'] },
54 { path: '/data-updates', expect: ['Data update log', 'ING-'] }, 55 { path: '/data-updates', expect: ['Data update log', 'ING-'] },
55 { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true }, 56 { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true },
56 { path: '/api/v1/trials/intelligence?limit=2', expect: ['data'], kind: 'json', optionalLocal: true }, 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 import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge'; 7 import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';
8 import { SourceBadge } from '@/components/ui/source-badge'; 8 import { SourceBadge } from '@/components/ui/source-badge';
9 import { Breadcrumbs } from '@/components/layout/breadcrumbs'; 9 import { Breadcrumbs } from '@/components/layout/breadcrumbs';
10+import { CountryActivity } from '@/components/country/activity';
10 import { TrendChart, type TrendSeries } from '@/components/charts/trend-chart'; 11 import { TrendChart, type TrendSeries } from '@/components/charts/trend-chart';
11 import { getGeographyBySlug, coverageFor, yearsFor, allSitesObservations, topCancersFor, trendFor, geographyScopeCode, WHO_REGION_LABEL, SEXES, BURDEN_METRICS, type Sex, type TopCancersResult } from '@/lib/queries/geography'; 12 import { getGeographyBySlug, coverageFor, yearsFor, allSitesObservations, topCancersFor, trendFor, geographyScopeCode, WHO_REGION_LABEL, SEXES, BURDEN_METRICS, type Sex, type TopCancersResult } from '@/lib/queries/geography';
12 import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology'; 13 import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';
@@ -62,6 +63,9 @@ export default async function CountryPage({ params, searchParams }: { params: Pr
62 </Link> 63 </Link>
63 </div> 64 </div>
64 </EmptyState> 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 </div> 69 </div>
66 ); 70 );
67 } 71 }
@@ -237,6 +241,8 @@ export default async function CountryPage({ params, searchParams }: { params: Pr
237 )} 241 )}
238 </Section> 242 </Section>
239 243
244+ <CountryActivity iso2={geo.iso2} iso3={geo.iso3} name={geo.name} kind={geo.kind} />
245+
240 {/* Sources & freshness */} 246 {/* Sources & freshness */}
241 <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."> 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 <div className="ci-table-wrap"> 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 <div className="mt-6 max-w-2xl"> 57 <div className="mt-6 max-w-2xl">
58 <CommandPalette variant="hero" /> 58 <CommandPalette variant="hero" />
59 <p className="mt-2 text-[12.5px] text-ink-3"> 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 </p> 61 </p>
62 </div> 62 </div>
63 </section> 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
Menu

Theme