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%

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 13 days ago (Sep 11, 2026) parent 4375731

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