diff --git a/README.md b/README.md index 9714588..7cec42c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # IRIS — A Privacy-First Clinical Trial Finder -A static web app that helps patients find relevant clinical trials from ClinicalTrials.gov. Search with a structured form or describe your situation in plain language and a small LLM running entirely in your browser fills in the form for you. Trials get rewritten into 8th-grade-reading-level summaries — also locally, also without sending anything to a server. +A static web app that helps patients find relevant clinical trials from ClinicalTrials.gov. Search with a structured form or describe your situation in plain language and a small LLM running entirely in your browser fills in the form for you. Trials get rewritten into 8th-grade-reading-level summaries — also locally, also without sending anything to a server. Pin trials, compare them side-by-side, and print a PDF to bring to your oncologist. **Live demo:** [johnoooh.github.io/iris](https://johnoooh.github.io/iris/) @@ -8,7 +8,7 @@ A static web app that helps patients find relevant clinical trials from Clinical ## Why this exists -Most clinical-trial finders either bury you under jargon designed for principal investigators or trade your search history for "personalization." IRIS does neither. There is no backend, no account, no analytics, no localStorage for user data. The only network requests are: +Most clinical-trial finders either bury you under jargon designed for principal investigators or trade your search history for "personalization." IRIS does neither. There is no backend, no account, no analytics, no `localStorage` for user data (one boolean for AI consent — that's it). The only network requests are: 1. ClinicalTrials.gov's public API (the search itself) 2. OpenStreetMap Nominatim (geocoding the location filter) @@ -24,17 +24,21 @@ The project is named for [**Iris Long**](https://en.wikipedia.org/wiki/Iris_Long ### Structured search (always available, zero LLM required) -A form maps directly to ClinicalTrials.gov v2 API parameters: condition, location + radius, age, gender, phase, recruitment status. Submitting hits the API and renders trials as cards with title, status, distance from your location, and contact information. +A form maps directly to ClinicalTrials.gov v2 API parameters: condition, location + radius, age, gender, phase, recruitment status. Submitting hits the API and renders trials in a two-pane triage layout (compact list on the left, detail pane on the right) that collapses to a tap-to-open bottom sheet on mobile. ### Natural-language input (opt-in, ~1.3 GB local model) -Type "I'm 58 years old with breast cancer in Boston" and a [Gemma 2 2B](https://huggingface.co/google/gemma-2-2b-it) model loaded via [WebLLM](https://webllm.mlc.ai/) extracts `condition`, `location`, `age`, and `sex` into the structured form. Works in English and Spanish. The model runs in a Web Worker against your GPU; the prompt and the patient's text never leave the device. +Type "I'm 58 years old with breast cancer in Boston" and a [Gemma 2 2B](https://huggingface.co/google/gemma-2-2b-it) model loaded via [WebLLM](https://webllm.mlc.ai/) extracts `condition`, `location`, `age`, and `sex`, then auto-fires the search — no second click required. Works in English and Spanish. The model runs in a Web Worker against your GPU; the prompt and the patient's text never leave the device. The textarea stays editable while the model downloads (~1.3 GB on first load, cached after); a submit during the download queues and auto-fires the moment the model becomes ready. -For other languages (Mandarin, Arabic, Cyrillic-script, etc.), extraction still works — the model translates condition names to English so the API can be queried — but plain-language simplification falls back to the source English with a hint pointing the user at their browser's built-in translate. This is a deliberate tradeoff: small enough models to be downloadable on a phone can't reliably generate medical content in non-Latin scripts. +For other languages (Mandarin, Arabic, Cyrillic-script, etc.), extraction still works — the prompt explicitly tells the model to translate condition and location names to English so the API can be queried — but plain-language simplification falls back to the source English with a hint pointing the user at their browser's built-in translate. This is a deliberate tradeoff: small enough models to be downloadable on a phone can't reliably generate medical content in non-Latin scripts. ### Plain-language simplification (opt-in, same model) -For each trial in the result list, the model rewrites the brief summary and eligibility criteria into accessible language at an 8th-grade reading level. A second pass compares the patient's description against the trial's eligibility and writes a hedged "this might or might not fit you" note. Both stream into the UI as the model produces tokens. +For each trial the user opens in the detail pane, the model rewrites the brief summary and eligibility criteria into accessible language at an 8th-grade reading level, streaming tokens into the UI as it produces them. A two-tier `
` disclaimer below the summary sets honest expectations: small AI models can miss eligibility details, so the patient should verify with their care team before acting. + +### Compare and print (no AI required) + +Pin up to 3 trials with the checkbox on each row. The pin set survives search refinements (pinned trials are cached even if a refined search no longer returns them — surfaced as a "(N not in current results)" badge). Click `Compare →` for a side-by-side field grid: status, phase, intervention, location, eligibility, contact, etc. Click `Print this trial` from the detail pane or `Print all (N)` from the compare view to generate a PDF via the browser's native print dialog — no library, searchable text, native a11y. The printed output expands all collapsed sections, spells out URLs after each link, includes an NCT ID + generated-on timestamp for the receiving doctor, and lays out one trial per page with a summary index. --- @@ -42,13 +46,18 @@ For each trial in the result list, the model rewrites the brief summary and elig ### Eval-driven model selection -The choice of Gemma 2 2B as the default wasn't theoretical. The repo includes a dev-only test harness (`?test=nlp`) that runs 20 multilingual prompts × N models and scores extraction accuracy plus latency. A second harness (`?test=scenarios`) runs end-to-end production scenarios — extraction → geocode → ClinicalTrials.gov search → simplification — across 20 patient cases and emits a markdown report. +The choice of Gemma 2 2B as the default wasn't theoretical. The repo includes three dev-only test harnesses: + +- `?test=nlp` — multilingual model evaluation across 20 prompts × N models, scoring extraction accuracy and latency. +- `?test=scenarios` — end-to-end production scenarios (extraction → geocode → ClinicalTrials.gov search → simplification) across 20 patient cases, emits a markdown report. +- `?test=classify` — a stage-1 binary classifier harness with 24 labeled trials, multilingual patient presets, translate-first toggle, production-realistic agreement metric, and copy-as-markdown output. Findings that shaped the code: - **Gemma 2 2B beats Qwen3 1.7B on Mandarin and Arabic** condition extraction; Qwen3 occasionally hallucinated wrong cancer types. -- **Gemma 3 1B (newer, more multilingual)** turned out to fall into a degenerate `-` token loop on our schema-with-rules extraction prompt — too small at 1B params with q4f16 quantization. Removed. -- **Qwen3 4B would handle Arabic better** but is 2.5 GB and runtime-incompatible with most phones — not shippable for the actual target user. -- The result: ship English + Spanish (both verified accurate), point other-language users at browser translate. Honest > leaky. +- **Qwen2.5-1.5B** edges Gemma slightly on binary classification accuracy (94% vs 93% in-scope) but its simplifier output is structurally unreliable on this prompt — kept as opt-in (`?model=qwen25`) for harness validation only. +- **Gemma 3 1B** fell into a degenerate `-` token loop on our schema-with-rules extraction prompt — too small at 1B params with q4f16 quantization. Removed. +- **Llama 3.2 3B** was catastrophic on classification (42% agreement, anchored on few-shot example demographics). Kept as opt-in (`?model=llama32`) for documentation but not recommended. +- The result: ship Gemma 2 2B for English + Spanish, point other-language users at browser translate, treat Qwen2.5 as a candidate worth fine-tuning. Honest > leaky. ### Failure-mode-anchored prompts @@ -62,6 +71,10 @@ Every rule in the extraction and simplification prompts traces to a specific obs That rule exists because Gemma 2 2B was confidently returning `MALE` for Arabic patients with breast cancer. The full set of rules is in [`src/utils/nlpHelpers.js`](src/utils/nlpHelpers.js) and [`src/utils/simplifyHelpers.js`](src/utils/simplifyHelpers.js). +### Single-engine serialization + +WebLLM's `MLCEngine` is not parallel-safe — concurrent `chat.completions.create()` calls clobber each other's state and produce `"Message error should not be 0"` failures. Both the simplifier (`useSimplifier`) and the classifier (`useClassifier`) serialize through their own promise chains so callers can fire-and-forget concurrently while the engine sees one task at a time. ([`src/hooks/useClassifier.js`](src/hooks/useClassifier.js)) + ### KV-cache management for browser LLMs WebLLM's `MLCEngine` retains conversation state across `chat.completions.create` calls by default. Running 20 simplifications back-to-back produced **7× latency growth** as the KV cache accumulated. Calling `engine.resetChat()` before each task — these are independent one-shot tasks, no follow-up turns — restored constant per-task latency. ([`src/workers/nlp.worker.js`](src/workers/nlp.worker.js)) @@ -72,14 +85,18 @@ If the model extracts `"California"` as the location, OpenStreetMap returns the ### Tree-shaken dev test panels -The two test harnesses are full React UIs, but they ship zero bytes to production. The route check is gated on `import.meta.env.DEV` and the panels are loaded via `lazy(() => import(...))` only in that branch. Vite resolves the conditional at build time and removes the entire module graph from `dist/`. Verified by grepping the production bundle. +The three test harnesses are full React UIs, but they ship zero bytes to production. The route check is gated on `import.meta.env.DEV` and the panels are loaded via `lazy(() => import(...))` only in that branch. Vite resolves the conditional at build time and removes the entire module graph from `dist/`. Verified by grepping the production bundle. ```js -const NLPTestPanel = import.meta.env.DEV - ? lazy(() => import('./components/NLPTestPanel')) +const ClassificationHarness = import.meta.env.DEV + ? lazy(() => import('./components/ClassificationHarness')) : null ``` +### Native browser print for the doctor handoff + +Compare-set and per-trial PDF export use `window.print()` plus a `@media print` stylesheet — no `jsPDF`, no `react-pdf`, no `html2canvas`. The resulting PDF has searchable text, respects screen-reader semantics, and adds zero bytes to the production bundle. The trade-off is layout constrained by the browser's print engine, which is fine for a clinical-trial summary. ([`src/styles/print.css`](src/styles/print.css)) + ### Streaming + queueing `useSimplifier` runs a FIFO queue against the shared worker, streams token chunks back into per-trial state, and parses the structured output (delimited by `## What this study is testing` and `## Who can join`) on every chunk so the UI renders progressively. Reasoning models (Qwen3) emit `...` blocks that the parser strips before applying the section delimiter. @@ -92,20 +109,27 @@ const NLPTestPanel = import.meta.env.DEV SearchForm ──┐ │ searchParams NaturalLang ──┴──► ResultsList ──► useClinicalTrials ──► ClinicalTrials.gov API - ▲ │ - │ │ detected language → outputLanguage + ▲ │ │ + │ │ └──► useClassifier (opt-in, gated, harness-only today) + │ │ │ ▼ └─── prefill ── useSimplifier ──► Web Worker (WebLLM) fields │ - └──► Gemma 2 2B / Qwen3 1.7B + └──► Gemma 2 2B (default) / Qwen3 1.7B + / Qwen2.5-1.5B / Llama 3.2 3B (opt-in) useNLP ────────────────────────────────► + +App ──► compareSet + pinnedTrials cache ──► CompareView at #/compare + │ + └──► window.print() + print.css ``` - **Layer 1** — structured search (always works, zero LLM dependency) -- **Layer 2** — natural-language input opt-in, populates Layer 1's form -- **Layer 3** — plain-language simplification opt-in, runs against Layer 1's results +- **Layer 2** — natural-language input opt-in, populates Layer 1's form, auto-fires the search +- **Layer 3** — plain-language simplification opt-in, runs against the selected trial in Layer 1's results +- **Layer 4** — compare + print, no LLM dependency -The single Web Worker owns one MLCEngine. `useNLP` and `useSimplifier` both attach listeners to it; the worker serializes message handling so extraction and simplification can't race. +The single Web Worker owns one MLCEngine. `useNLP`, `useSimplifier`, and `useClassifier` all attach listeners to it; the worker serializes message handling and each hook chains its own outbound requests so the engine sees one task at a time. --- @@ -113,19 +137,19 @@ The single Web Worker owns one MLCEngine. `useNLP` and `useSimplifier` both atta ```bash npm install -npm run dev # http://localhost:5173/iris/ +npm run dev # http://localhost:5173/iris/ (port pinned via strictPort) npm run build # static build → dist/ npm run preview # preview the production build -npm run test:run # 190 tests +npm run test:run # 197 tests ``` -Dev-only test harnesses: +Dev-only test harnesses (live in DEV bundle only): -- `http://localhost:5173/iris/?test=nlp` — multilingual model evaluation +- `http://localhost:5173/iris/?test=nlp` — multilingual NLP extraction evaluation - `http://localhost:5173/iris/?test=scenarios` — end-to-end scenario runner -- Append `&model=qwen3` to either to compare against Qwen3 1.7B +- `http://localhost:5173/iris/?test=classify` — stage-1 binary classifier harness with multilingual patient presets -Models load on first opt-in and persist in the browser's WebLLM cache. +Append `&model=qwen25`, `&model=llama32`, or `&model=qwen3` to any of those URLs (or to the main app) to swap the active LLM. Models load on first opt-in and persist in the browser's WebLLM cache (keyed by origin). --- @@ -138,12 +162,12 @@ Models load on first opt-in and persist in the browser's WebLLM cache. | Data | TanStack Query + ClinicalTrials.gov v2 API | | Geocoding | OpenStreetMap Nominatim | | LLM runtime | [@mlc-ai/web-llm](https://webllm.mlc.ai/) 0.2.83 (WebGPU) | -| Models | Gemma 2 2B (default, ~1.3 GB) · Qwen3 1.7B (option, ~1.1 GB) | -| Tests | Vitest (190 tests, 14 files) | +| Models | Gemma 2 2B (default, ~1.3 GB) · Qwen3 1.7B (~1.1 GB) · Qwen2.5-1.5B (~900 MB) · Llama 3.2 3B (~1.9 GB) — all opt-in via `?model=` | +| Tests | Vitest (197 tests, 15 files) | +| CI | GitHub Actions: `npm ci → lint → test → build` on every PR to main | | Deploy | Static build to GitHub Pages | -**Bundle:** main JS 84 KB gzip · LLM worker 1.5 MB gzip (lazy, only on opt-in) -**Lighthouse:** 100 / 100 / 100 / 100 (Performance / A11y / Best Practices / SEO) +**Bundle:** main JS 91 KB gzip · LLM worker 1.5 MB gzip (lazy, only on opt-in) --- @@ -156,20 +180,21 @@ The production bundle makes outbound requests to: - `nominatim.openstreetmap.org/search` — geocoding the location filter - `huggingface.co/mlc-ai/...` — one-time model download, only after explicit user consent -That's the complete list. No analytics, no telemetry, no error reporting, no fonts/CDNs, no service worker push. `localStorage` is used for exactly one boolean: whether the user has consented to download the LLM (so they aren't re-prompted on every visit). +That's the complete list. No analytics, no telemetry, no error reporting, no fonts/CDNs, no service worker push. `localStorage` is used for exactly one boolean (`iris_nlp_enabled`): whether the user has consented to download the LLM, so they aren't re-prompted on every visit. The compare-pin set lives in memory only — refreshing clears it, by design. --- ## Roadmap - [x] Phase 1 — Structured search MVP -- [x] Phase 2 — Natural-language input via local Gemma 2B (English + Spanish) -- [x] Phase 3 — Plain-language simplification with multilingual scoping -- [ ] Phase 4 — Larger model when WebLLM ships Gemma 3 4B (broader language coverage) -- [ ] Phase 5 — Multilingual UI strings for non-Latin-script users +- [x] Phase 2 — Natural-language input via local Gemma 2B (English + Spanish), auto-search after extraction +- [x] Phase 3 — Plain-language simplification with multilingual scoping; two-tier oncologist disclaimer +- [x] Phase 4 — Compare workflow (pin up to 3, side-by-side view) + browser-native PDF/print export for doctor handoff +- [ ] Phase 5 — Stage-1 binary classifier wired into results UI (validation harness already lives at `?test=classify`; in-app surfacing gated until "Best fit" sort lands) +- [ ] Phase 6 — Domain-specific LoRA fine-tune of Qwen2.5-1.5B to push classifier and simplifier accuracy past the stock-model ceiling (planning + dataset-curation docs in the project vault) --- ## Acknowledgements -[Iris Long](https://en.wikipedia.org/wiki/ACT_UP), for showing what it looks like to make complicated things accessible to the people they affect. Larry Kramer, the Treatment and Data Committee, and ACT-UP New York for the broader fight. The teams behind [WebLLM](https://webllm.mlc.ai/), [Gemma](https://ai.google.dev/gemma), and [ClinicalTrials.gov](https://clinicaltrials.gov/) for the open infrastructure that makes a no-backend version of this possible. +[Iris Long](https://en.wikipedia.org/wiki/Iris_Long), for showing what it looks like to make complicated things accessible to the people they affect. Larry Kramer, the Treatment and Data Committee, and ACT-UP New York for the broader fight. The teams behind [WebLLM](https://webllm.mlc.ai/), [Gemma](https://ai.google.dev/gemma), and [ClinicalTrials.gov](https://clinicaltrials.gov/) for the open infrastructure that makes a no-backend version of this possible. diff --git a/src/App.jsx b/src/App.jsx index b4ef669..479617e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,10 +1,14 @@ -import { lazy, Suspense, useState } from 'react' +import { lazy, Suspense, useCallback, useRef, useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import Header from './components/Header' import UnifiedSearchBar from './components/UnifiedSearchBar' import ResultsList from './components/ResultsList' +import CompareView from './components/CompareView' import Footer from './components/Footer' import { resolveModelKey } from './utils/nlpModels' +import { useHashRoute } from './hooks/useHashRoute' + +const COMPARE_LIMIT = 3 const queryClient = new QueryClient() @@ -38,6 +42,60 @@ function IrisApp() { resolveModelKey(typeof window !== 'undefined' ? window.location.search : '') ) + // ─── Compare selection (lifted from ResultsList) ────────────────── + // Lives at App level so it survives search refinements (each new + // search re-mounts ResultsList, which would have wiped local state). + // pinnedTrials is a parallel cache of the full trial objects keyed + // by NCT ID — needed because a previously-pinned trial may not appear + // in the current result set, but the compare view still needs to + // render it. Cache is in-memory only (no localStorage) per the + // privacy promise. + const [compareSet, setCompareSet] = useState(() => new Set()) + const pinnedTrialsRef = useRef(new Map()) + + const toggleCompare = useCallback((trial) => { + if (!trial?.nctId) return + setCompareSet(prev => { + const next = new Set(prev) + if (next.has(trial.nctId)) { + next.delete(trial.nctId) + } else if (next.size < COMPARE_LIMIT) { + next.add(trial.nctId) + // Populate the cache the moment a trial is pinned so the + // compare view has the data even after the user refines + // their search and the result set no longer contains it. + pinnedTrialsRef.current.set(trial.nctId, trial) + } + return next + }) + }, []) + + const clearCompare = useCallback(() => { + setCompareSet(new Set()) + pinnedTrialsRef.current.clear() + }, []) + + const removeFromCompare = useCallback((nctId) => { + setCompareSet(prev => { + const next = new Set(prev) + next.delete(nctId) + return next + }) + pinnedTrialsRef.current.delete(nctId) + }, []) + + const { route, navigate } = useHashRoute() + if (route === '/compare') { + return ( + navigate('/')} + onRemove={removeFromCompare} + /> + ) + } + const testRoute = getTestRoute() if (testRoute === 'nlp' && NLPTestPanel) { return ( @@ -90,6 +148,11 @@ function IrisApp() { modelKey={modelKey} userDescription={userDescription} extractedFields={prefill} + compareSet={compareSet} + compareLimit={COMPARE_LIMIT} + onToggleCompare={toggleCompare} + onClearCompare={clearCompare} + onRemoveFromCompare={removeFromCompare} /> )} diff --git a/src/components/CompareView.jsx b/src/components/CompareView.jsx new file mode 100644 index 0000000..a2734f6 --- /dev/null +++ b/src/components/CompareView.jsx @@ -0,0 +1,328 @@ +// Side-by-side compare view for up to 3 pinned trials. +// +// Desktop (≥900px): table layout with field labels in the left column and +// one column per trial. Familiar tabular comparison. +// +// Mobile (<900px): stacked cards, one per trial, each with the same field +// rows. The table's responsive collapse uses `display: block` on td/tr — +// preserves semantics for screen readers but lets each "row" of one trial +// flow as its own block. (Decided against trying 2-col at tablet — at +// 768px each trial column is ~240px which crushes the eligibility text.) + +import { nearestLocation } from '../utils/apiHelpers' + +const STATUS_LABEL = { + RECRUITING: 'Recruiting', + NOT_YET_RECRUITING: 'Not yet recruiting', + ACTIVE_NOT_RECRUITING: 'Active, not recruiting', + COMPLETED: 'Completed', + TERMINATED: 'Terminated', + WITHDRAWN: 'Withdrawn', +} + +const PHASE_SHORT = { + EARLY_PHASE1: 'Early Phase 1', + PHASE1: 'Phase 1', + PHASE2: 'Phase 2', + PHASE3: 'Phase 3', + PHASE4: 'Phase 4', + NA: 'N/A', +} + +function formatPhase(phases) { + if (!phases?.length) return '—' + return phases.map(p => PHASE_SHORT[p] ?? p).join(' / ') +} + +function locationLabel(trial) { + const nearest = nearestLocation(trial.locations, null) + if (!nearest) { + if (trial.locations?.length) { + const l = trial.locations[0] + return [l.facility, l.city, l.state].filter(Boolean).join(', ') + } + return '—' + } + return [nearest.facility, nearest.city, nearest.state].filter(Boolean).join(', ') +} + +function ageRange(eligibility) { + const min = eligibility?.minAge + const max = eligibility?.maxAge + if (!min && !max) return '—' + return `${min || 'N/A'} – ${max || 'N/A'}` +} + +function sexLabel(sex) { + if (!sex || sex === 'ALL') return 'Any' + return sex.charAt(0) + sex.slice(1).toLowerCase() +} + +const FIELDS = [ + { key: 'status', label: 'Status', render: (t) => STATUS_LABEL[t.status] || t.status || '—' }, + { key: 'phase', label: 'Phase', render: (t) => formatPhase(t.phases) }, + { key: 'intervention', label: 'Intervention', render: (t) => t.interventions?.map(i => i.name).filter(Boolean).join(', ') || '—' }, + { key: 'location', label: 'Nearest location', render: locationLabel }, + { key: 'sex', label: 'Sex', render: (t) => sexLabel(t.eligibility?.sex) }, + { key: 'age', label: 'Age range', render: (t) => ageRange(t.eligibility) }, + { key: 'eligibility', label: 'Eligibility criteria', render: (t) => t.eligibility?.criteria || '—', long: true }, + { key: 'summary', label: 'Brief summary', render: (t) => t.summary || '—', long: true }, + { key: 'contact', label: 'Contact', render: (t) => contactBlock(t) }, + { key: 'link', label: 'ClinicalTrials.gov', render: (t) => ( + + {t.nctId} + + )}, +] + +function contactBlock(trial) { + const c = trial.contact || {} + const parts = [] + if (c.name) parts.push(c.name) + if (c.phone) parts.push(c.phone) + if (c.email) parts.push({c.email}) + if (parts.length === 0) return '—' + return ( +
+ {parts.map((p, i) => {p})} +
+ ) +} + +function printAll() { + document.body.setAttribute('data-print-scope', 'compare') + const cleanup = () => { + document.body.removeAttribute('data-print-scope') + window.removeEventListener('afterprint', cleanup) + } + window.addEventListener('afterprint', cleanup) + window.print() +} + +export default function CompareView({ compareSet, pinnedTrials, onBack, onRemove }) { + const trials = [] + for (const nctId of compareSet) { + const trial = pinnedTrials.get(nctId) + if (trial) trials.push(trial) + } + + return ( +
+
+ +

+ compare trials +

+
+ + {trials.length} pinned + + {trials.length > 0 && ( + + )} +
+
+ +
+ {trials.length === 0 ? ( +

+ No trials pinned yet. Use the checkbox on each trial to add up to 3. +

+ ) : ( + <> + {/* Screen view: table on desktop, stacked cards on mobile. + no-print hides this so PrintCompare below takes over. */} +
+ +
+ {/* Print view: a one-line index page + one full trial per page */} + + + )} +
+
+ ) +} + +// Print-only structure — hidden on screen via .print-only, takes over on +// print. The table layout doesn't paginate cleanly (10 fields × 3 columns +// crushes per-page), so we lay out one trial per page in a vertical stack +// using the same .iris-print-trial wrappers that print.css adds page breaks +// between. +function PrintCompare({ trials }) { + const dateStr = new Intl.DateTimeFormat(undefined, { + dateStyle: 'long', + timeStyle: 'short', + }).format(new Date()) + return ( +
+ {/* Index page: one line per trial, before the per-trial pages start. */} +
+

+ Clinical trial summary — {trials.length} trial{trials.length === 1 ? '' : 's'} +

+

+ Generated {dateStr} · IRIS · clinicaltrials.gov +

+ + + + + + + + + + + {trials.map(t => ( + + + + + + + ))} + +
NCTTitlePhaseStatus
{t.nctId}{t.title}{formatPhase(t.phases)}{STATUS_LABEL[t.status] || t.status || '—'}
+

+ The trials below were pinned by a patient using IRIS, an open-source clinical-trial discovery + tool. Eligibility and contact data come directly from ClinicalTrials.gov. Discuss with your + care team whether any of these may be appropriate. +

+
+ + {/* Per-trial pages — page break between via print.css */} + {trials.map(t => ( +
+

+ {t.title} +

+

+ {t.nctId} · {STATUS_LABEL[t.status] || t.status || ''} · {formatPhase(t.phases)} +

+
+ {FIELDS.map(field => ( +
+
+ {field.label} +
+
+ {field.render(t)} +
+
+ ))} +
+
+ ))} +
+ ) +} + +function CompareTable({ trials, onRemove }) { + return ( + <> + {/* Desktop: real table, side-by-side */} + + + + {trials.map(t => )} + + + + + ))} + + + + {FIELDS.map(field => ( + + + {trials.map(t => ( + + ))} + + ))} + +
+ {trials.map(t => ( + +
+

+ {t.title} +

+ +
+

{t.nctId}

+
+ {field.label} + + {field.render(t)} +
+ + {/* Mobile / tablet portrait: stacked cards, one per trial */} +
+ {trials.map(t => ( +
+
+
+

+ {t.title} +

+

{t.nctId}

+
+ +
+
+ {FIELDS.map(field => ( +
+
+ {field.label} +
+
+ {field.render(t)} +
+
+ ))} +
+
+ ))} +
+ + ) +} diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 2fd1328..463baea 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -1,6 +1,6 @@ export default function Footer() { return ( -