// Ireland Clinical Trials Performance Index: renders one edition to static HTML (2026-10-27). // Reads public_html/data/trials_index/.json (compute.mjs) and writes // public_html/data/trials_index/.body.html, which trials-index.php wraps in the site header and footer. // Charts are HTML and inline SVG built here, so every number is in the page source (no client-side charting). // Usage: node scripts/trials_index/render.mjs 2026-q4 import fs from 'fs'; const EDITION = process.argv[2] || '2026-q4'; const D = JSON.parse(fs.readFileSync(`public_html/data/trials_index/${EDITION}.json`, 'utf8')); const OUT = `public_html/data/trials_index/${EDITION}.body.html`; const DL = `/downloads/trials-index/${EDITION}`; const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const n1 = v => v == null ? 'n/a' : Number(v).toLocaleString('en-IE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); const n0 = v => v == null ? 'n/a' : Math.round(v).toLocaleString('en-IE'); const ord = n => n + (['th', 'st', 'nd', 'rd'][(n % 100 - 20) % 10] || ['th', 'st', 'nd', 'rd'][n % 100] || 'th'); const C = D.countries, R = D.ranks, IE = C.Ireland, I = D.ireland; const IND = Object.fromEntries(D.indicators.map(i => [i.key, i])); const fmt = (k, v) => /Share$/.test(k) ? n1(v) + '%' : n1(v); const countries = Object.keys(C); const peers = countries.filter(c => c !== 'Ireland'); const pubDate = new Date(D.published + 'T12:00:00Z').toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric' }); const dataDate = new Date(D.fetchedAt.Ireland).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric' }); const lastT = D.trend[D.trend.length - 1]; const gaps = D.trend.map(t => t.peerMedian - t.ie); const belowAll = gaps.every(g => g > 0); const pm = I.phaseMix, pmTot = Object.values(pm).reduce((a, b) => a + b, 0); const top = R.startsPm.order[0]; const ratioTop = C[top].startsPm / IE.startsPm; // ── small builders ── const tip = (t) => ` tabindex="0" data-tip="${esc(t)}"`; /** Rank strip: every country as a dot on one axis, Ireland emphasised, peer median as a tick. */ function strip(key) { const ind = IND[key], vals = countries.map(c => [c, C[c][key]]).filter(([, v]) => v != null); const lo = Math.min(...vals.map(v => v[1])), hi = Math.max(...vals.map(v => v[1])); const pad = (hi - lo) * 0.06 || 1, a = lo - pad, b = hi + pad; const x = v => ((v - a) / (b - a) * 100).toFixed(2); const flip = ind.dir === -1; // lower is better: draw better to the right for every row const pos = v => flip ? (100 - x(v)).toFixed(2) : x(v); const med = R[key]?.median; const dots = vals.filter(([c]) => c !== 'Ireland').map(([c, v]) => `${D.codes[c]}`).join(''); return ``; } /** Above / below / level against the peer median, in words as well as colour. */ function signal(key) { const d = IND[key].dir, v = IE[key], m = R[key].median; if (v === m) return '= level with median'; return (v - m) * d > 0 ? '▲ above median' : '▼ below median'; } /** Horizontal bars, one series; Ireland (or a named row) emphasised. Values at the tip. */ function hbars(rows, { max, unit = '', emph = 'Ireland', valFmt = n1, label = r => r[0] } = {}) { const m = max ?? Math.max(...rows.map(r => r[1])); return `
    ${rows.map(r => { const w = (r[1] / m).toFixed(4), e = r[0] === emph; return `
  1. ${esc(label(r))}${valFmt(r[1])}${unit}
  2. `; }).join('')}
`; } /** Two-part horizontal bars (industry + other), legend above. */ function hstack(rows, keys, labels) { const m = Math.max(...rows.map(r => r.total)); return `
${labels[0]}${labels[1]}
    ${rows.map(r => { const a = r[keys[0]], b = r.total - a; return `${esc(r.name)}${r.total}`; }).join('')}
`; } /** Line chart, two series on one axis: Ireland vs peer median. HTML axis labels over a stretched SVG plot. */ function lineChart(trend) { const ys = trend.map(t => t.year), maxV = Math.ceil(Math.max(...trend.flatMap(t => [t.ie, t.peerMedian])) / 10) * 10; const X = i => i / (ys.length - 1) * 100, Y = v => 100 - v / maxV * 100; const path = k => trend.map((t, i) => `${i ? 'L' : 'M'}${X(i).toFixed(2)},${Y(t[k]).toFixed(2)}`).join(' '); const gap = `M${trend.map((t, i) => `${X(i).toFixed(2)},${Y(t.peerMedian).toFixed(2)}`).join(' L')} L${[...trend].reverse().map((t, i) => `${X(trend.length - 1 - i).toFixed(2)},${Y(t.ie).toFixed(2)}`).join(' L')} Z`; const ticks = [0, maxV / 4, maxV / 2, maxV * 3 / 4, maxV]; const last = trend[trend.length - 1]; return `
IrelandMedian of the ten comparison countries
${ticks.map(t => `${t}`).join('')}
${ticks.map(t => ``).join('')} ${trend.map((t, i) => ``).join('')} ${n1(last.peerMedian)} ${n1(last.ie)} Gap ${last.year}: ${n1(last.peerMedian - last.ie)}
${ys.map((y, i) => `${y}`).join('')}
`; } /** Quarterly columns, stacked industry + other; provisional quarters drawn lighter. */ function quarterCols(q, provisional) { const keys = Object.keys(q).sort().filter(k => k !== '2026 Q4'); const m = Math.ceil(Math.max(...keys.map(k => q[k].n)) / 10) * 10; const full = keys.filter(k => !provisional.includes(k) && +k.slice(0, 4) < 2026); const avg = full.reduce((a, k) => a + q[k].n, 0) / full.length; return `
Industry sponsoredUniversities, hospitals and networksPaler: provisional, late registrations still arriving${full[0].slice(0, 4)} to ${full[full.length - 1].slice(0, 4)} average
${[0, m / 2, m].map(t => `${t}`).join('')}
${[0, m / 2, m].map(t => ``).join('')} Average ${n1(avg)} a quarter ${keys.map(k => { const v = q[k], p = provisional.includes(k); return `
${v.n}
`; }).join('')}
${keys.map(k => `${k.replace(' ', '
')}
`).join('')}
`; } /** 100% bar of the phase mix (ordered categories, one-hue ramp). */ function phaseBar(mix) { const tot = Object.values(mix).reduce((a, b) => a + b, 0); const ent = Object.entries(mix); return `
${ent.map(([k, v], i) => ``).join('')}
`; } /** Ireland against the peer median for one indicator, two short bars. */ function compare(key, label, unit = '') { const vals = countries.map(c => C[c][key]).filter(v => v != null), mx = Math.max(...vals); const pmed = peers.map(c => C[c][key]).sort((a, b) => a - b), h = pmed.length >> 1; const med = Math.round((pmed.length % 2 ? pmed[h] : (pmed[h - 1] + pmed[h]) / 2) * 10) / 10; const row = (n, v, ie) => `
${n}${n1(v)}${unit}
`; return `

${label}

${row('Ireland', IE[key], 1)}${row('Peer median', med)}
`; } // ── content ── const lowest = R.startsPm.order[R.startsPm.order.length - 1]; const findings = [ { k: n1(IE.startsPm), l: `trials per million people a year, ${ord(R.startsPm.ie)} of ${R.startsPm.of}`, t: `Ireland started ${n0(IE.starts)} interventional trials in ${D.windows.starts[0]} to ${D.windows.starts[1]}. The peer median is ${n1(R.startsPm.median)}, and ${top} runs ${n1(ratioTop)} times as many per head.${R.startsPm.order[R.startsPm.order.length - 2] === 'Ireland' ? ` Only ${lowest} is lower.` : ''}` }, { k: n0(D.gap.perYear), l: 'more trials a year would reach the peer median', t: `Most of the gap is in academic and hospital-led research. On industry-sponsored trials alone, Ireland is about ${n0(D.gap.industryPerYear)} a year short.` }, { k: n1(IE.completedShare) + '%', l: `completed rather than stopped early, ${ord(R.completedShare.ie)} of ${R.completedShare.of}`, t: `Results reach ClinicalTrials.gov for ${n1(IE.resultsAnyShare)}% of completed phase 2 to 4 trials (${ord(R.resultsAnyShare.ie)}), but only ${n1(IE.results12Share)}% within 12 months of completion, the usual legal deadline.` }, { k: n1(IE.earlyShare) + '%', l: `of new trials are early phase, ${R.earlyShare.tiedWith.length ? 'joint ' : ''}${ord(R.earlyShare.ie)} of ${R.earlyShare.of}`, t: `${IE.earlyStarts} early-phase trials in five years, against ${n1(C.Netherlands.earlyShare)}% in the Netherlands and ${n1(C.Belgium.earlyShare)}% in Belgium. Registration before the first patient is also low: ${n1(IE.prospectiveShare)}% (${ord(R.prospectiveShare.ie)}).` }, ]; const scoreRows = D.indicators.filter(i => i.dir !== 0).map(i => `

${esc(i.label)}

${esc(i.unit)}${i.dir === -1 ? ' · lower is better' : ''}

${strip(i.key)}
${signal(i.key)}${ord(R[i.key].ie)}of ${R[i.key].of}${R[i.key].tiedWith?.length ? `, joint with ${R[i.key].tiedWith.join(', ')}` : ''}
`).join(''); const tableHead = D.indicators.map(i => `${esc(i.label)}${esc(i.unit)}`).join(''); const tableRows = [...countries].sort((a, b) => C[b].startsPm - C[a].startsPm).map(c => `${c}${D.indicators.map(i => `${fmt(i.key, C[c][i.key])}`).join('')}`).join(''); const why = Object.entries(I.whyStopped).sort((a, b) => b[1] - a[1]); const areas = I.areas.map(a => ({ name: a.area, total: a.n, industry: a.industry })); const hosp = I.hospitals.map(h => { const [name, city] = h.site.replace(/\bSt\. /g, 'St ').split(/, (?=[^,]+$)/); return [city && !name.includes(city) ? `${name}, ${city}` : name, h.n]; }); const spons = I.sponsors.slice(0, 10); const prov = ['2026 Q2', '2026 Q3']; // citation formats (no DOI until the edition is archived on Zenodo; the page says so rather than inventing one) const ED_LABEL = EDITION.toUpperCase().replace('-', ' '); const URL_TI = 'https://priyalifescience.com/trials-index'; const YEAR = D.published.slice(0, 4); const pubLong = pubDate; const CITES = [ ['apa', 'APA 7', `Priya Life Science. (${YEAR}). Ireland Clinical Trials Performance Index (Edition 1, ${ED_LABEL}) [Data set]. ${URL_TI}`], ['harvard', 'Harvard', `Priya Life Science (${YEAR}) Ireland Clinical Trials Performance Index, edition 1 (${ED_LABEL}) [Dataset]. Available at: ${URL_TI} (Accessed: ${pubLong}).`], ['vancouver', 'Vancouver', `Priya Life Science. Ireland Clinical Trials Performance Index, edition 1 (${ED_LABEL}) [dataset on the Internet]. Priya Life Science; ${YEAR} [cited ${D.published}]. Available from: ${URL_TI}`], ['bibtex', 'BibTeX', `@misc{pls_trials_index_${EDITION.replace('-', '')}, author = {{Priya Life Science}}, title = {Ireland Clinical Trials Performance Index, edition 1 (${ED_LABEL})}, year = {${YEAR}}, howpublished = {\\url{${URL_TI}}}, note = {Data set. Licence CC BY 4.0. Published ${D.published}} }`], ['ris', 'RIS', `TY - DATA AU - Priya Life Science TI - Ireland Clinical Trials Performance Index, edition 1 (${ED_LABEL}) PY - ${YEAR} DA - ${D.published.replace(/-/g, '/')} PB - Priya Life Science UR - ${URL_TI} N1 - Licence CC BY 4.0 ER - `], ]; const creditLine = `Source: Priya Life Science, Ireland Clinical Trials Performance Index, ${ED_LABEL} (CC BY 4.0)`; const html = `

Data deskEdition 1${EDITION.toUpperCase().replace('-', ' ')}

Ireland Clinical Trials Performance Index

How Ireland compares with ten European countries of similar size on starting, running and reporting clinical trials. Built from ${n0(D.uniqueTrials)} registered trials (${n0(D.countryRecords)} country records), every number traceable to the public registry.

Published
${pubDate}
Data to
${dataDate}
Countries
${countries.length}
Licence
CC BY 4.0
Next edition
January 2027

Photo: CDC laboratory, via Unsplash

Headline figures

Trials started per million people a year, ${D.windows.starts[0]} to ${D.windows.starts[1]}

${n1(IE.startsPm)}

Ireland${n1(IE.startsPm)}
Peer median${n1(R.startsPm.median)}
${top}${n1(C[top].startsPm)}

${ord(R.startsPm.ie)} of ${R.startsPm.of} countries

The gap

${n0(D.gap.perYear)}

more trials a year would bring Ireland to the median

Recruiting now

${n0(I.recruitingNow)}

interventional trials with an Irish site listed as recruiting on ${dataDate}

Strength

${ord(R.resultsAnyShare.ie)}

of ${R.resultsAnyShare.of} for posting results (${n1(IE.resultsAnyShare)}% of completed phase 2 to 4 trials)

Early-phase share

${n1(IE.earlyShare)}%

of new trials are early phase; the Netherlands is at ${n1(C.Netherlands.earlyShare)}%

01

What the index shows

    ${findings.map(f => `
  1. ${f.k}

    ${esc(f.l)}

    ${f.t}

  2. `).join('')}

02

The scorecard

Eight indicators, eleven countries. Each dot is a country; better is always to the right. Hover or tap a dot for its value.

IrelandComparison countryMedian of the 10 comparison countriesBetter →
${scoreRows}

Comparison countries: ${peers.join(', ')}. Chosen as European countries of 5 to 18 million people with established clinical research systems. A trial with sites in several countries counts in each of them.

03

Activity: a gap that has not closed

Trials started per million people, by year. ${belowAll ? `Ireland has been below the median of the comparison countries in every year since ${D.trend[0].year}.` : ''} In ${lastT.year} Ireland started ${n1(lastT.ie)} trials per million against a median of ${n1(lastT.peerMedian)}.

Trials started per million people, ${D.trend[0].year} to ${D.trend[D.trend.length - 1].year}

${lineChart(D.trend)}

Interventional trials with an actual start date and at least one site in the country. Source: ClinicalTrials.gov; Eurostat population 2025.

All trials per million a year, ${D.windows.starts[0]} to ${D.windows.starts[1]}

${hbars(R.startsPm.order.map(c => [c, C[c].startsPm]))}

Industry-sponsored trials per million a year

${hbars(R.industryPm.order.map(c => [c, C[c].industryPm]))}

04

The quarterly pulse

Trials that started at Irish sites each quarter since 2023. Sponsors often confirm a start date months later, so the latest two quarters will rise in the next edition.

${quarterCols(I.quarters, prov)}

05

Running and reporting trials

Ireland's record once a trial is open is better than its volume. Completion and results reporting sit at or above the middle of the group.

Completed, not stopped early

${n1(IE.completedShare)}%

of ${n0(IE.closed)} trials that closed in ${D.windows.completion[0]} to ${D.windows.completion[1]}.

Peer median ${n1(R.completedShare.median)}% · ${ord(R.completedShare.ie)} of ${R.completedShare.of}

Results posted

${n1(IE.resultsAnyShare)}%

of ${n0(IE.resultsBase)} completed phase 2 to 4 trials; ${n1(IE.results12Share)}% within 12 months.

Peer median ${n1(R.resultsAnyShare.median)}% · ${ord(R.resultsAnyShare.ie)} of ${R.resultsAnyShare.of}

Registered before the first patient

${n1(IE.prospectiveShare)}%

of trials started in ${D.windows.starts[0]} to ${D.windows.starts[1]}.

Peer median ${n1(R.prospectiveShare.median)}% · ${ord(R.prospectiveShare.ie)} of ${R.prospectiveShare.of}

Why trials with Irish sites stopped early

${I.terminatedBase} terminated trials, primary completion ${D.windows.completion[0]} to ${D.windows.completion[1]}, each reason read and grouped by hand.

${hbars(why, { emph: 'Too few patients enrolled', valFmt: n0 })}

Highlighted: recruitment, the reason Irish hospitals, networks and funders can act on directly.

06

Inside Ireland: where, what and who

The ${n0(I.windowStarts)} trials that started at Irish sites in ${D.windows.starts[0]} to ${D.windows.starts[1]}.

Hospitals and units with the most trials

Named sites only: ${n1(I.namedSiteShare)}% of trials name at least one Irish site.

${hbars(hosp, { emph: '', valFmt: n0 })}

Therapy areas

A trial can count in more than one area.

${hstack(areas, ['industry'], ['Industry sponsored', 'Other sponsors'])}

Phase mix

Phase 3 and non-phased studies make up ${n1((pm['Phase 3'] + pm['Not phased (devices, procedures, behaviour)']) / pmTot * 100)}% of Irish trials.

${phaseBar(I.phaseMix)}
${compare('nationalOnlyShare', 'Run only in this country, % of new trials', '%')} ${compare('medianMonthsIndustryLate', 'Median trial length in months, industry phase 2 to 4')}

Lead sponsors

${spons.map(s => ``).join('')}
SponsorTypeTrials
${esc(s.sponsor)}${s.industry ? 'Industry' : 'Academic'}${s.n}

07

The full table and the data

Every indicator for every country. Free to reuse under CC BY 4.0 with credit to Priya Life Science.

${tableHead}${tableRows}
Indicators by country, edition ${EDITION}
Country

08

Methodology and limits

What is counted

Every interventional study on ClinicalTrials.gov with at least one site in the country and a start date from 2014, downloaded through the registry's public API on ${dataDate}. Population is Eurostat's figure for 1 January 2025. No figure is estimated or modelled.

A start or completion date counts as having happened when the registry marks it actual. Records last updated before 2017 carry no date type; for those, a past date counts when the trial's status shows it ran.

The indicators

Trials started per million
Trials with an actual start date in ${D.windows.starts[0]} to ${D.windows.starts[1]}, divided by five and by population in millions.
Industry-sponsored trials per million
The same, for trials whose lead sponsor is a company.
Early-phase share
Share of those trials registered as early phase 1, phase 1 or phase 1/2.
Registered before the first patient
Share first submitted to the registry in or before the month the trial started.
Completed rather than stopped early
Of trials with an actual primary completion in ${D.windows.completion[0]} to ${D.windows.completion[1]} that are now completed or terminated, the share completed.
Results posted
Of completed phase 2, 3 and 4 trials with primary completion in ${D.windows.results[0]} to ${D.windows.results[1]}, the share with results on ClinicalTrials.gov at all, and within 365 days.
Records gone stale
Share of trials started since 2014 whose status is Unknown: the registry's label for a trial still listed as open whose status has not been verified for more than two years past its expected completion date.

Limits to keep in mind

  • One registry only. Trials registered only in the EU Clinical Trials Information System or EudraCT are not counted, and results posted only to EU registers are not seen. This affects every country, but not equally.
  • A multinational trial counts in every country where it has a site. The index measures participation, not where a trial is led.
  • Dates, phases and sponsor types are as sponsors registered them. Recent quarters are provisional because start dates are often confirmed late.
  • Hospital names are matched across spellings; sites listed only as "Research Site" cannot be attributed.
  • Reasons for stopping were read one by one and grouped by hand; the grouping is ours, not the sponsor's.

Process

The scripts that download, count and draw this edition, and the hand-review of stopping reasons, are published with the data. Each figure was produced by code, reviewed, verified and approved by a human editor. Before publication every figure was recomputed by separately written code and key counts were re-queried from the live registry; the quality-check report is published with the data. Errors are fixed openly on our corrections page.

09

Cite and reuse

Free to reuse under CC BY 4.0. Credit Priya Life Science and name the edition, so readers can find the exact figures you used.

${CITES.map(([id, name], i) => ``).join('')}
${CITES.map(([id, name, text], i) => `
${esc(text)}
`).join('')}

Credit line for charts and articles

${esc(creditLine)}

Reference manager files: citation.bib · citation.ris

Partner on the 2027 editions

The index is free and open. Organisations that want to support the quarterly editions, or a deeper cut for their therapy area, can talk to our team. Supporters never see or change findings before release.

info@priyalifescience.com
`; fs.writeFileSync(OUT, html); fs.writeFileSync(`public_html/downloads/trials-index/${EDITION}/citation.bib`, CITES.find(c => c[0] === 'bibtex')[2] + '\n'); fs.writeFileSync(`public_html/downloads/trials-index/${EDITION}/citation.ris`, CITES.find(c => c[0] === 'ris')[2].replace(/\n/g, '\r\n') + '\r\n'); fs.copyFileSync(`public_html/data/trials_index/${EDITION}.json`, `public_html/downloads/trials-index/${EDITION}/index.json`); console.log('wrote', OUT, html.length, 'bytes');